Compare commits
10 Commits
5087f35861
...
0e69a86a89
| Author | SHA1 | Date | |
|---|---|---|---|
|
0e69a86a89
|
|||
|
81ed58ecff
|
|||
|
d4bf8a8cad
|
|||
|
5fb2f4df43
|
|||
|
2dbbb1b706
|
|||
|
4e077d878e
|
|||
|
7f7f5f69d4
|
|||
|
4af3ad2dde
|
|||
|
08b707f602
|
|||
|
7419bcb125
|
@@ -8,4 +8,10 @@ FROM gcr.io/distroless/static-debian12
|
|||||||
COPY jellybit /usr/local/bin/jellybit
|
COPY jellybit /usr/local/bin/jellybit
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# В distroless нет shell/curl — проверку делает сам бинарь (порт берёт из
|
||||||
|
# /data/config.toml). compose может переопределить параметры healthcheck.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD ["/usr/local/bin/jellybit", "healthcheck"]
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/jellybit", "--config", "/data/config.toml"]
|
ENTRYPOINT ["/usr/local/bin/jellybit", "--config", "/data/config.toml"]
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Jellybit
|
# Jellybit
|
||||||
|
|
||||||
Jellybit — связующий сервис между qBittorrent и Jellyfin. Принимает
|
Jellybit — связующий сервис между qBittorrent и Jellyfin. Принимает
|
||||||
торрент (magnet, `.torrent` или ссылку) вместе с текстовым контекстом,
|
magnet-ссылку вместе с текстовым контекстом, ставит загрузку в
|
||||||
ставит загрузку в qBittorrent, дожидается её завершения, распознаёт
|
qBittorrent, дожидается её завершения, распознаёт содержимое (фильм или
|
||||||
содержимое (фильм или сериал, сезоны и серии) и раскладывает готовые
|
сериал, сезоны и серии) и раскладывает готовые файлы по конвенциям
|
||||||
файлы по конвенциям библиотеки Jellyfin.
|
библиотеки Jellyfin.
|
||||||
|
|
||||||
Полный замысел и причины — в [BRIEF.md](BRIEF.md).
|
Полный замысел и причины — в [BRIEF.md](BRIEF.md).
|
||||||
|
|
||||||
@@ -18,8 +18,8 @@ Arr-стек (prowlarr/radarr/sonarr) плохо ложится на русск
|
|||||||
|
|
||||||
## Как работает
|
## Как работает
|
||||||
|
|
||||||
1. Точка входа принимает torrent/magnet + контекст (HTTP API, веб-UI
|
1. Точка входа принимает magnet + контекст (HTTP API, веб-UI,
|
||||||
или Telegram-бот).
|
Telegram-бот или CLI).
|
||||||
2. Загрузка ставится в qBittorrent в выделенную категорию.
|
2. Загрузка ставится в qBittorrent в выделенную категорию.
|
||||||
3. Сервис отслеживает завершение загрузки.
|
3. Сервис отслеживает завершение загрузки.
|
||||||
4. По именам файлов, контексту и (опц.) базам метаданных определяется
|
4. По именам файлов, контексту и (опц.) базам метаданных определяется
|
||||||
@@ -30,13 +30,20 @@ Arr-стек (prowlarr/radarr/sonarr) плохо ложится на русск
|
|||||||
При высокой уверенности раскладка выполняется автоматически, иначе —
|
При высокой уверенности раскладка выполняется автоматически, иначе —
|
||||||
уходит на подтверждение человеку.
|
уходит на подтверждение человеку.
|
||||||
|
|
||||||
|
Доступ к внешним сервисам (LLM, базы метаданных, Telegram) при
|
||||||
|
необходимости идёт через HTTP-прокси — задаётся полем `proxy` в
|
||||||
|
соответствующих секциях конфигурации.
|
||||||
|
|
||||||
## Статус
|
## Статус
|
||||||
|
|
||||||
Ранняя разработка. Готовы каркас (Ф0) и приём + трекинг (Ф1): добавление
|
Рабочий прототип с полным сквозным путём: приём magnet → загрузка в
|
||||||
magnet в qBittorrent, идемпотентность по infohash, поллинг завершения и
|
qBittorrent → распознавание (LLM + опционально базы метаданных
|
||||||
машина состояний (`downloading → completed`, плюс stuck/failed); наружу —
|
TMDB/TVDB/TVMaze) → раскладка в библиотеку хардлинками, автоматически при
|
||||||
REST API, веб-UI и `jellybit add`. Источники кроме magnet (.torrent/url) и
|
уверенном результате либо через подтверждение человеком. Транспорты приёма:
|
||||||
распознавание (Ф2) — дальше. См. [дорожную карту](docs/drafts/roadmap.md).
|
REST API, веб-UI, Telegram-бот и CLI (`jellybit add`).
|
||||||
|
|
||||||
|
Из источников пока поддержан magnet; `.torrent` и обычные ссылки — в планах.
|
||||||
|
См. [дорожную карту](docs/drafts/roadmap.md).
|
||||||
|
|
||||||
## Документация
|
## Документация
|
||||||
|
|
||||||
@@ -67,7 +74,25 @@ task build # статический бинарь (linu
|
|||||||
task image # docker-образ из готового бинаря
|
task image # docker-образ из готового бинаря
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Отладка распознавания на реальной раздаче (только чтение, без раскладки):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
jellybit recognize <infohash> --dry-run [--context "..."] --config ./config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Берёт торрент из qBittorrent по infohash, прогоняет распознавание (LLM +
|
||||||
|
метабазы) и печатает план: тип/название/год, матч в базе, решение авто/review
|
||||||
|
и превью целевых путей — то, что создалось бы при Apply.
|
||||||
|
|
||||||
## Доставка
|
## Доставка
|
||||||
|
|
||||||
Сборка здесь → готовый бинарь копируется на медиа-сервер umbar
|
Рассчитан на домашний медиа-сервер. Артефакты репозитория — статический
|
||||||
(`/home/av/projects/private/umbar`). Деплой-обвязка живёт в umbar.
|
бинарь (`task build`) и `Dockerfile` (упаковка в `distroless/static`). Образ
|
||||||
|
собирается **на сервере** из доставленного бинаря, поэтому Go-тулчейн на
|
||||||
|
сервере не нужен. В distroless нет shell/curl, поэтому HEALTHCHECK зовёт сам
|
||||||
|
бинарь: `jellybit healthcheck` (GET `/healthz` по порту из конфига, exit 0/1).
|
||||||
|
|
||||||
|
Контейнер: `user 1000:1000`, порт `8080` на хост, mount `/srv/media` (единая
|
||||||
|
песочница для хардлинков) + data-том с `config.toml`/SQLite; к qBittorrent —
|
||||||
|
по сети Docker. Конкретная деплой-обвязка (плейбук, секреты) держится в
|
||||||
|
отдельном приватном репозитории и в комплект не входит.
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runHealthcheck дёргает /healthz локального сервиса и завершается с кодом 0
|
||||||
|
// при 200, иначе ненулевым. Нужен для HEALTHCHECK в distroless-образе, где
|
||||||
|
// нет shell/curl: docker зовёт сам бинарь.
|
||||||
|
func runHealthcheck(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("healthcheck", flag.ContinueOnError)
|
||||||
|
configPath := fs.String("config", "/data/config.toml", "путь к config.toml")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// listen вида ":8080" или "127.0.0.1:8080" → стучимся на localhost:<port>.
|
||||||
|
_, port, err := net.SplitHostPort(cfg.HTTP.Listen)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse http.listen %q: %w", cfg.HTTP.Listen, err)
|
||||||
|
}
|
||||||
|
url := "http://127.0.0.1:" + port + "/healthz"
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("healthcheck request: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("healthcheck: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serveHealthz поднимает http-сервер на свободном порту, отдавая указанный
|
||||||
|
// статус на /healthz. Возвращает порт и стоп-функцию.
|
||||||
|
func serveHealthz(t *testing.T, status int) int {
|
||||||
|
t.Helper()
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(status)
|
||||||
|
})
|
||||||
|
srv := &http.Server{Handler: mux}
|
||||||
|
go func() { _ = srv.Serve(ln) }()
|
||||||
|
t.Cleanup(func() { _ = srv.Close() })
|
||||||
|
return ln.Addr().(*net.TCPAddr).Port
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeConfig(t *testing.T, port int) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "config.toml")
|
||||||
|
content := "[http]\nlisten = \"127.0.0.1:" + strconv.Itoa(port) + "\"\n"
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthcheck_OK(t *testing.T) {
|
||||||
|
port := serveHealthz(t, http.StatusOK)
|
||||||
|
if err := runHealthcheck([]string{"--config", writeConfig(t, port)}); err != nil {
|
||||||
|
t.Errorf("healthcheck: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthcheck_BadStatus(t *testing.T) {
|
||||||
|
port := serveHealthz(t, http.StatusServiceUnavailable)
|
||||||
|
if err := runHealthcheck([]string{"--config", writeConfig(t, port)}); err == nil {
|
||||||
|
t.Error("ожидалась ошибка при 503")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthcheck_NoServer(t *testing.T) {
|
||||||
|
// Порт, на котором никто не слушает (берём свободный и закрываем).
|
||||||
|
ln, _ := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
port := ln.Addr().(*net.TCPAddr).Port
|
||||||
|
_ = ln.Close()
|
||||||
|
if err := runHealthcheck([]string{"--config", writeConfig(t, port)}); err == nil {
|
||||||
|
t.Error("ожидалась ошибка без сервера")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@
|
|||||||
//
|
//
|
||||||
// Подкоманды:
|
// Подкоманды:
|
||||||
//
|
//
|
||||||
// jellybit [serve] --config <path> запустить сервис (по умолчанию)
|
// jellybit [serve] --config <path> запустить сервис (по умолчанию)
|
||||||
// jellybit add <magnet> [--context] добавить загрузку через REST API сервиса
|
// jellybit add <magnet> [--context] добавить загрузку через REST API сервиса
|
||||||
|
// jellybit recognize <infohash> --dry-run показать план распознавания (без записи)
|
||||||
|
// jellybit healthcheck --config <p> проверить /healthz (для docker HEALTHCHECK)
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -27,6 +29,10 @@ func main() {
|
|||||||
err = runServe(args)
|
err = runServe(args)
|
||||||
case "add":
|
case "add":
|
||||||
err = runAdd(args)
|
err = runAdd(args)
|
||||||
|
case "recognize":
|
||||||
|
err = runRecognize(args)
|
||||||
|
case "healthcheck":
|
||||||
|
err = runHealthcheck(args)
|
||||||
default:
|
default:
|
||||||
_, _ = os.Stderr.WriteString("unknown command: " + cmd + "\n")
|
_, _ = os.Stderr.WriteString("unknown command: " + cmd + "\n")
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/config"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runRecognize — диагностика распознавания: берёт торрент из qBittorrent по
|
||||||
|
// infohash, прогоняет конвейер (LLM + метабазы) и печатает план раскладки.
|
||||||
|
// Только чтение: ни записи в БД, ни хардлинков.
|
||||||
|
func runRecognize(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("recognize", flag.ContinueOnError)
|
||||||
|
configPath := fs.String("config", "/data/config.toml", "путь к config.toml")
|
||||||
|
dryRun := fs.Bool("dry-run", true, "только показать план, без изменений (единственный режим)")
|
||||||
|
contextStr := fs.String("context", "", "доп. текстовый контекст для распознавания")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
infohash := strings.ToLower(strings.TrimSpace(fs.Arg(0)))
|
||||||
|
// flag останавливается на первом позиционном — допарсим флаги, стоящие
|
||||||
|
// после <infohash> (напр. `recognize <infohash> --dry-run`).
|
||||||
|
if fs.NArg() > 1 {
|
||||||
|
if err := fs.Parse(fs.Args()[1:]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if infohash == "" {
|
||||||
|
return fmt.Errorf("usage: jellybit recognize <infohash> [--dry-run] [--context ...]")
|
||||||
|
}
|
||||||
|
if !*dryRun {
|
||||||
|
return fmt.Errorf("recognize runs only in --dry-run mode; layout is applied via review")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cfg.LLM.Type == "" || cfg.LLM.BaseURL == "" {
|
||||||
|
return fmt.Errorf("[llm] is not configured in config — recognition is unavailable")
|
||||||
|
}
|
||||||
|
// Внутренние логи (ретраи/ошибки провайдеров) — в stderr, чтобы не мешать
|
||||||
|
// плану в stdout.
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// qBittorrent: ищем торрент и его файлы.
|
||||||
|
qb, err := qbt.New(qbt.Config{
|
||||||
|
URL: cfg.QBittorrent.URL,
|
||||||
|
Username: cfg.QBittorrent.Username,
|
||||||
|
Password: cfg.QBittorrent.Password,
|
||||||
|
}, logger)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
torrents, err := qb.Torrents(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("qbittorrent: %w", err)
|
||||||
|
}
|
||||||
|
t, ok := findTorrent(torrents, infohash)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("torrent with infohash %s not found in qBittorrent", infohash)
|
||||||
|
}
|
||||||
|
files, err := qb.Files(ctx, t.Hash)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("qbittorrent files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Провайдеры метаданных + LLM + распознаватель.
|
||||||
|
providers, err := metadataProviders(cfg, logger)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
provider, err := llm.New(llm.Config{
|
||||||
|
Type: cfg.LLM.Type, BaseURL: cfg.LLM.BaseURL, APIKey: cfg.LLM.APIKey,
|
||||||
|
Model: cfg.LLM.Model, Proxy: cfg.LLM.Proxy, Timeout: cfg.LLM.Timeout.Std(),
|
||||||
|
}, logger)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rec := recognize.New(provider, providers, recognize.Config{
|
||||||
|
MaxRetries: cfg.LLM.MaxRetries,
|
||||||
|
AutoThreshold: cfg.Recognition.AutoConfidenceThreshold,
|
||||||
|
}, logger)
|
||||||
|
|
||||||
|
in := recognize.Input{Name: t.Name, Context: *contextStr}
|
||||||
|
for _, f := range files {
|
||||||
|
in.Files = append(in.Files, recognize.File{Path: f.Name, Size: f.Size})
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
res, err := rec.Recognize(ctx, in)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("recognize: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Раскладчик для превью (BuildLinks ничего не пишет; логгер не нужен).
|
||||||
|
lay, err := layout.New(layout.Config{MoviesDir: cfg.Paths.Movies, SeriesDir: cfg.Paths.Series}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printDryRun(t, files, res, lay, providerNames(providers), time.Since(start))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findTorrent(torrents []qbt.Torrent, infohash string) (qbt.Torrent, bool) {
|
||||||
|
for _, t := range torrents {
|
||||||
|
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
|
||||||
|
if h != "" && strings.EqualFold(h, infohash) {
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return qbt.Torrent{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func printDryRun(t qbt.Torrent, files []qbt.File, res recognize.Result, lay *layout.Layouter, providers []string, took time.Duration) {
|
||||||
|
p := res.Plan
|
||||||
|
fmt.Printf("════ Торрент ════\n")
|
||||||
|
fmt.Printf("name : %s\n", t.Name)
|
||||||
|
fmt.Printf("infohash : %s\n", t.Hash)
|
||||||
|
fmt.Printf("save_path : %s\n", t.SavePath)
|
||||||
|
fmt.Printf("файлов : %d state: %s\n\n", len(files), t.State)
|
||||||
|
|
||||||
|
fmt.Printf("════ Распознавание ════\n")
|
||||||
|
fmt.Printf("провайдеры базы: %v\n", providers)
|
||||||
|
fmt.Printf("заняло : %s, попыток LLM: %d\n", took.Truncate(time.Millisecond), res.Attempts)
|
||||||
|
fmt.Printf("тип : %s\n", p.Type)
|
||||||
|
fmt.Printf("название : %s", p.Title)
|
||||||
|
if p.OriginalTitle != "" {
|
||||||
|
fmt.Printf(" (ориг: %s)", p.OriginalTitle)
|
||||||
|
}
|
||||||
|
fmt.Printf("\nгод : %d\n", p.Year)
|
||||||
|
fmt.Printf("self-confidence: %.2f\n", p.Confidence)
|
||||||
|
if p.Notes != "" {
|
||||||
|
fmt.Printf("notes : %s\n", p.Notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n──── Матч в базе ────\n")
|
||||||
|
if m := res.Match; m != nil {
|
||||||
|
fmt.Printf("provider=%s id=%s title=%q year=%d\n", m.Provider, m.ProviderID, m.Title, m.Year)
|
||||||
|
if len(m.SeasonEpisodeCounts) > 0 {
|
||||||
|
fmt.Printf("серий по сезонам в базе: %v\n", m.SeasonEpisodeCounts)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf("единичного сильного матча нет\n")
|
||||||
|
}
|
||||||
|
if len(res.Candidates) > 0 {
|
||||||
|
fmt.Printf("кандидаты для ручного выбора (%d):\n", len(res.Candidates))
|
||||||
|
for _, c := range res.Candidates {
|
||||||
|
tagP, tagID := recognize.CandidateTag(c)
|
||||||
|
fmt.Printf(" · %s/%s %q (%d) [тег: %s-%s]\n", c.Provider, c.ID, c.Title, c.Year, tagP, tagID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n──── Решение ────\n")
|
||||||
|
if res.Decision.Auto {
|
||||||
|
fmt.Printf("АВТО-раскладка (review не нужен)\n")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("REVIEW — причины:\n")
|
||||||
|
for _, reason := range res.Decision.Reasons {
|
||||||
|
fmt.Printf(" · %s\n", reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n──── Превью раскладки (хардлинки НЕ создаются) ────\n")
|
||||||
|
tag := ""
|
||||||
|
if res.Match != nil {
|
||||||
|
tag = worker.ProviderTag(res.Match.Provider, res.Match.ProviderID)
|
||||||
|
}
|
||||||
|
links, err := lay.BuildLinks(worker.ToLayoutPlan(p, t.SavePath, tag))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("план не построился: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, l := range links {
|
||||||
|
fmt.Printf(" [%s] %s\n", l.Kind, l.Dst)
|
||||||
|
}
|
||||||
|
fmt.Printf("\nИтого ссылок: %d (это создалось бы при Apply)\n", len(links))
|
||||||
|
}
|
||||||
|
|
||||||
|
func providerNames(providers []metadata.Provider) []string {
|
||||||
|
out := make([]string, len(providers))
|
||||||
|
for i, p := range providers {
|
||||||
|
out[i] = p.Name()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRecognize_RequiresInfohash(t *testing.T) {
|
||||||
|
if err := runRecognize(nil); err == nil || !strings.Contains(err.Error(), "usage") {
|
||||||
|
t.Errorf("без infohash ожидалась usage-ошибка, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecognize_DryRunOnly(t *testing.T) {
|
||||||
|
// Флаг после позиционного должен разобраться (допарсинг), а --dry-run=false
|
||||||
|
// — отклониться до обращения к конфигу/сети.
|
||||||
|
err := runRecognize([]string{"abc123", "--dry-run=false"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "dry-run") {
|
||||||
|
t.Errorf("ожидалась ошибка про dry-run, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
-9
@@ -5,11 +5,15 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/config"
|
"git.vakhrushev.me/av/jellybit/internal/config"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/httpapi"
|
"git.vakhrushev.me/av/jellybit/internal/httpapi"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
||||||
@@ -20,6 +24,7 @@ import (
|
|||||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/tgbot"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,7 +56,7 @@ func runServe(args []string) error {
|
|||||||
URL: cfg.QBittorrent.URL,
|
URL: cfg.QBittorrent.URL,
|
||||||
Username: cfg.QBittorrent.Username,
|
Username: cfg.QBittorrent.Username,
|
||||||
Password: cfg.QBittorrent.Password,
|
Password: cfg.QBittorrent.Password,
|
||||||
})
|
}, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -62,7 +67,7 @@ func runServe(args []string) error {
|
|||||||
}, logger)
|
}, logger)
|
||||||
|
|
||||||
// Ф4: базы метаданных (опц.). Без них авто-раскладки нет — всё в review.
|
// Ф4: базы метаданных (опц.). Без них авто-раскладки нет — всё в review.
|
||||||
providers, err := metadataProviders(cfg)
|
providers, err := metadataProviders(cfg, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -81,7 +86,7 @@ func runServe(args []string) error {
|
|||||||
Model: cfg.LLM.Model,
|
Model: cfg.LLM.Model,
|
||||||
Proxy: cfg.LLM.Proxy,
|
Proxy: cfg.LLM.Proxy,
|
||||||
Timeout: cfg.LLM.Timeout.Std(),
|
Timeout: cfg.LLM.Timeout.Std(),
|
||||||
})
|
}, logger)
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
return fmt.Errorf("llm provider: %w", perr)
|
return fmt.Errorf("llm provider: %w", perr)
|
||||||
}
|
}
|
||||||
@@ -97,14 +102,16 @@ func runServe(args []string) error {
|
|||||||
layouter, err := layout.New(layout.Config{
|
layouter, err := layout.New(layout.Config{
|
||||||
MoviesDir: cfg.Paths.Movies,
|
MoviesDir: cfg.Paths.Movies,
|
||||||
SeriesDir: cfg.Paths.Series,
|
SeriesDir: cfg.Paths.Series,
|
||||||
})
|
}, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("layouter: %w", err)
|
return fmt.Errorf("layouter: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
wrk := worker.New(st, qb, recognizer, layouter, worker.Config{
|
wrk := worker.New(st, qb, recognizer, layouter, worker.Config{
|
||||||
Category: cfg.QBittorrent.Category,
|
Category: cfg.QBittorrent.Category,
|
||||||
|
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(),
|
||||||
@@ -124,6 +131,32 @@ func runServe(args []string) error {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
|
// Ф5: Telegram-транспорт + пинги. Доступ — по allowed_user_ids
|
||||||
|
// (пусто = запрет всем, fail-closed). Недоступность Telegram на старте не
|
||||||
|
// валит сервис — бот просто отключается.
|
||||||
|
if cfg.Telegram.Enabled {
|
||||||
|
if cfg.Telegram.Token == "" {
|
||||||
|
return fmt.Errorf("telegram enabled, but token is empty")
|
||||||
|
}
|
||||||
|
tgClient, perr := telegramHTTPClient(cfg.Telegram.Proxy)
|
||||||
|
if perr != nil {
|
||||||
|
return perr
|
||||||
|
}
|
||||||
|
api, terr := tgbotapi.NewBotAPIWithClient(cfg.Telegram.Token, tgbotapi.APIEndpoint, tgClient)
|
||||||
|
if terr != nil {
|
||||||
|
logger.Error("telegram bot disabled: cannot connect", "err", terr)
|
||||||
|
} else {
|
||||||
|
bot := tgbot.New(api, ingestor, wrk, tgbot.Config{
|
||||||
|
AllowedUserIDs: cfg.Telegram.AllowedUserIDs,
|
||||||
|
WebBaseURL: cfg.Telegram.WebBaseURL,
|
||||||
|
}, logger)
|
||||||
|
wrk.SetNotifier(bot)
|
||||||
|
go bot.Run(ctx)
|
||||||
|
logger.Info("telegram bot enabled",
|
||||||
|
"bot", api.Self.UserName, "allowed_users", len(cfg.Telegram.AllowedUserIDs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
go wrk.Run(ctx)
|
go wrk.Run(ctx)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
@@ -156,27 +189,58 @@ func runServe(args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// telegramHTTPClient собирает HTTP-клиент бота с опц. прокси. Таймаута уровня
|
||||||
|
// клиента нет намеренно — он порвал бы long-poll; вместо этого ограничиваем
|
||||||
|
// установление соединения (dial/TLS из DefaultTransport) и ожидание заголовков
|
||||||
|
// ответа с запасом над long-poll (30с в tgbot). Так мёртвый прокси не подвешивает
|
||||||
|
// ни отправку уведомлений, ни приёмный цикл навсегда — клиент переподключится.
|
||||||
|
func telegramHTTPClient(proxy string) (*http.Client, error) {
|
||||||
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
if proxy != "" {
|
||||||
|
proxyURL, err := url.Parse(proxy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("telegram: parse proxy %q: %w", proxy, err)
|
||||||
|
}
|
||||||
|
transport.Proxy = http.ProxyURL(proxyURL)
|
||||||
|
}
|
||||||
|
transport.ResponseHeaderTimeout = 45 * time.Second
|
||||||
|
return &http.Client{Transport: transport}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// metadataProviders собирает включённые конфигом базы метаданных. Для
|
// metadataProviders собирает включённые конфигом базы метаданных. Для
|
||||||
// сериалов Jellyfin привычнее tvdbid, поэтому TVDB идёт первым.
|
// сериалов Jellyfin привычнее tvdbid, поэтому TVDB идёт первым.
|
||||||
func metadataProviders(cfg *config.Config) ([]metadata.Provider, error) {
|
func metadataProviders(cfg *config.Config, logger *slog.Logger) ([]metadata.Provider, error) {
|
||||||
var out []metadata.Provider
|
var out []metadata.Provider
|
||||||
if cfg.Metadata.TVDB.Enabled {
|
// TVMaze без ключа и покрывает сериалы — ставим первым.
|
||||||
|
if cfg.Metadata.TVMaze.Enabled {
|
||||||
|
p, err := metadata.NewTVMaze(metadata.TVMazeConfig{
|
||||||
|
Proxy: cfg.Metadata.TVMaze.Proxy,
|
||||||
|
Timeout: cfg.Metadata.TVMaze.Timeout.Std(),
|
||||||
|
}, logger)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tvmaze provider: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
// TVDB/TMDB включаются ключом: если enabled, но ключ пуст — тихо
|
||||||
|
// пропускаем (сервис стартует), а не падаем.
|
||||||
|
if cfg.Metadata.TVDB.Enabled && cfg.Metadata.TVDB.APIKey != "" {
|
||||||
p, err := metadata.NewTVDB(metadata.TVDBConfig{
|
p, err := metadata.NewTVDB(metadata.TVDBConfig{
|
||||||
APIKey: cfg.Metadata.TVDB.APIKey,
|
APIKey: cfg.Metadata.TVDB.APIKey,
|
||||||
Proxy: cfg.Metadata.TVDB.Proxy,
|
Proxy: cfg.Metadata.TVDB.Proxy,
|
||||||
Timeout: cfg.Metadata.TVDB.Timeout.Std(),
|
Timeout: cfg.Metadata.TVDB.Timeout.Std(),
|
||||||
})
|
}, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("tvdb provider: %w", err)
|
return nil, fmt.Errorf("tvdb provider: %w", err)
|
||||||
}
|
}
|
||||||
out = append(out, p)
|
out = append(out, p)
|
||||||
}
|
}
|
||||||
if cfg.Metadata.TMDB.Enabled {
|
if cfg.Metadata.TMDB.Enabled && cfg.Metadata.TMDB.APIKey != "" {
|
||||||
p, err := metadata.NewTMDB(metadata.TMDBConfig{
|
p, err := metadata.NewTMDB(metadata.TMDBConfig{
|
||||||
APIKey: cfg.Metadata.TMDB.APIKey,
|
APIKey: cfg.Metadata.TMDB.APIKey,
|
||||||
Proxy: cfg.Metadata.TMDB.Proxy,
|
Proxy: cfg.Metadata.TMDB.Proxy,
|
||||||
Timeout: cfg.Metadata.TMDB.Timeout.Std(),
|
Timeout: cfg.Metadata.TMDB.Timeout.Std(),
|
||||||
})
|
}, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("tmdb provider: %w", err)
|
return nil, fmt.Errorf("tmdb provider: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-6
@@ -1,14 +1,14 @@
|
|||||||
# Пример конфигурации jellybit. Реальный config.toml рендерится Ansible'ом
|
# Пример конфигурации jellybit. Реальный config.toml не коммитится (содержит
|
||||||
# из переменных umbar и не коммитится (секреты — vars/secrets.yml).
|
# секреты). Для локального запуска: db_path -> ./jellybit.db.
|
||||||
# Для локального запуска: db_path -> ./jellybit.db.
|
|
||||||
|
|
||||||
[qbittorrent]
|
[qbittorrent]
|
||||||
url = "http://qbit:8989" # по имени сервиса в общей docker-сети
|
url = "http://qbit:8989" # по имени сервиса в общей docker-сети
|
||||||
username = "admin"
|
username = "admin"
|
||||||
password = ""
|
password = ""
|
||||||
category = "jellybit"
|
category = "jellybit" # категория для добавляемых jellybit раздач (push)
|
||||||
|
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"
|
||||||
@@ -40,6 +40,11 @@ api_key = ""
|
|||||||
proxy = ""
|
proxy = ""
|
||||||
timeout = "10s"
|
timeout = "10s"
|
||||||
|
|
||||||
|
[metadata.tvmaze]
|
||||||
|
enabled = false # без ключа; только сериалы, тег [tvdbid-…] из externals
|
||||||
|
proxy = ""
|
||||||
|
timeout = "10s"
|
||||||
|
|
||||||
[worker]
|
[worker]
|
||||||
poll_interval = "5s"
|
poll_interval = "5s"
|
||||||
stuck_after = "1h"
|
stuck_after = "1h"
|
||||||
@@ -52,10 +57,12 @@ auto_confidence_threshold = 0.85
|
|||||||
enabled = false
|
enabled = false
|
||||||
token = ""
|
token = ""
|
||||||
allowed_user_ids = [] # пусто = запрет всем (fail-closed)
|
allowed_user_ids = [] # пусто = запрет всем (fail-closed)
|
||||||
|
web_base_url = "" # напр. "http://jellybit:8080" — для кнопки «открыть в вебе»
|
||||||
|
proxy = "" # опц. HTTP-прокси для api.telegram.org
|
||||||
|
|
||||||
[http]
|
[http]
|
||||||
listen = ":8080"
|
listen = ":8080"
|
||||||
trusted_subnets = [] # опц. allowlist подсетей; пусто = без ограничений
|
trusted_subnets = [] # ПОКА НЕ ПРИМЕНЯЕТСЯ (деплой только в LAN); зарезервировано
|
||||||
|
|
||||||
[log]
|
[log]
|
||||||
level = "info"
|
level = "info"
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ qBittorrent, определяет содержимое (фильм или сер
|
|||||||
| `worker` | владелец машины состояний; поллинг, сериализация команд |
|
| `worker` | владелец машины состояний; поллинг, сериализация команд |
|
||||||
| `recognize` | пред-парс имени + вызов LLM + модель уверенности |
|
| `recognize` | пред-парс имени + вызов LLM + модель уверенности |
|
||||||
| `llm` | провайдер LLM за интерфейсом (дискриминатор `type`) |
|
| `llm` | провайдер LLM за интерфейсом (дискриминатор `type`) |
|
||||||
| `metadata` | интерфейс баз метаданных + TMDB/TVDB (опц.) |
|
| `metadata` | интерфейс баз метаданных + TMDB/TVDB/TVMaze (опц.) |
|
||||||
| `layout` | конвенции Jellyfin, санитизация путей, хардлинкер, undo |
|
| `layout` | конвенции Jellyfin, санитизация путей, хардлинкер, undo |
|
||||||
| `store` | SQLite: загрузки, распознавание, подсказки, ссылки |
|
| `store` | SQLite: загрузки, распознавание, подсказки, ссылки |
|
||||||
| `httpapi` | REST + веб-UI (server-rendered, htmx) |
|
| `httpapi` | REST + веб-UI (server-rendered, htmx) |
|
||||||
@@ -84,9 +84,10 @@ SQLite; на старте `worker` сверяет категорию qBittorrent
|
|||||||
reject / defer / undo) — команды к `worker`:
|
reject / defer / undo) — команды к `worker`:
|
||||||
|
|
||||||
- **HTTP API + веб-UI** — форма «добавить», список, экран ревью
|
- **HTTP API + веб-UI** — форма «добавить», список, экран ревью
|
||||||
(server-rendered + htmx). В v1 **без авторизации** (доверенная LAN), с
|
(server-rendered). В v1 **без авторизации** (доверенная LAN). Поле
|
||||||
опциональным allowlist подсетей (`http.trusted_subnets`). Защиту
|
`http.trusted_subnets` зарезервировано, но **пока не применяется**:
|
||||||
навесим позже — [drafts/ideas.md](../drafts/ideas.md).
|
деплой только в локальную сеть без доступа из интернета, поэтому
|
||||||
|
allowlist-middleware и авторизацию отложили — [drafts/ideas.md](../drafts/ideas.md).
|
||||||
- **Telegram-бот** — переслать magnet/сообщение бота; текст становится
|
- **Telegram-бот** — переслать magnet/сообщение бота; текст становится
|
||||||
контекстом. Доступ — по `telegram.allowed_user_ids` (пусто = запрет
|
контекстом. Доступ — по `telegram.allowed_user_ids` (пусто = запрет
|
||||||
всем, fail-closed). Бот же шлёт **пинги** о входе в review/готовности.
|
всем, fail-closed). Бот же шлёт **пинги** о входе в review/готовности.
|
||||||
@@ -141,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]
|
||||||
@@ -189,7 +192,7 @@ allowed_user_ids = [] # пусто = запрет всем (fail-closed)
|
|||||||
|
|
||||||
[http]
|
[http]
|
||||||
listen = ":8080"
|
listen = ":8080"
|
||||||
trusted_subnets = [] # опц. allowlist подсетей; пусто = без ограничений
|
trusted_subnets = [] # зарезервировано, ПОКА НЕ ПРИМЕНЯЕТСЯ (деплой только в LAN)
|
||||||
|
|
||||||
[log]
|
[log]
|
||||||
level = "info"
|
level = "info"
|
||||||
@@ -214,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).
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ go 1.26
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-chi/chi/v5 v5.1.0
|
github.com/go-chi/chi/v5 v5.1.0
|
||||||
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||||
github.com/jmoiron/sqlx v1.4.0
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4
|
github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3
|
github.com/pelletier/go-toml/v2 v2.2.3
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
|||||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||||
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
||||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
|||||||
@@ -26,10 +26,15 @@ type Config struct {
|
|||||||
|
|
||||||
// QBittorrent — доступ к qBittorrent WebUI и раскладка путей загрузок.
|
// QBittorrent — доступ к qBittorrent WebUI и раскладка путей загрузок.
|
||||||
type QBittorrent struct {
|
type QBittorrent struct {
|
||||||
URL string `toml:"url"`
|
URL string `toml:"url"`
|
||||||
Username string `toml:"username"`
|
Username string `toml:"username"`
|
||||||
Password string `toml:"password"`
|
Password string `toml:"password"`
|
||||||
Category string `toml:"category"`
|
// Category — категория для добавляемых jellybit раздач (push, savepath).
|
||||||
|
Category string `toml:"category"`
|
||||||
|
// Tag — метка для усыновления существующих раздач (pull, не трогает
|
||||||
|
// категорию/savepath). Discovery подхватывает раздачи с этой категорией
|
||||||
|
// ИЛИ этим тегом.
|
||||||
|
Tag string `toml:"tag"`
|
||||||
SavePath string `toml:"savepath"`
|
SavePath string `toml:"savepath"`
|
||||||
PathMap map[string]string `toml:"path_map"`
|
PathMap map[string]string `toml:"path_map"`
|
||||||
}
|
}
|
||||||
@@ -59,11 +64,13 @@ type LLM struct {
|
|||||||
|
|
||||||
// Metadata — внешние базы метаданных (опциональны).
|
// Metadata — внешние базы метаданных (опциональны).
|
||||||
type Metadata struct {
|
type Metadata struct {
|
||||||
TMDB MetadataProvider `toml:"tmdb"`
|
TMDB MetadataProvider `toml:"tmdb"`
|
||||||
TVDB MetadataProvider `toml:"tvdb"`
|
TVDB MetadataProvider `toml:"tvdb"`
|
||||||
|
TVMaze MetadataProvider `toml:"tvmaze"` // без ключа, только сериалы
|
||||||
}
|
}
|
||||||
|
|
||||||
// MetadataProvider — настройки одного провайдера метаданных.
|
// MetadataProvider — настройки одного провайдера метаданных. У keyless-баз
|
||||||
|
// (TVMaze) поле api_key не используется.
|
||||||
type MetadataProvider struct {
|
type MetadataProvider struct {
|
||||||
Enabled bool `toml:"enabled"`
|
Enabled bool `toml:"enabled"`
|
||||||
APIKey string `toml:"api_key"`
|
APIKey string `toml:"api_key"`
|
||||||
@@ -88,11 +95,16 @@ type Telegram struct {
|
|||||||
Enabled bool `toml:"enabled"`
|
Enabled bool `toml:"enabled"`
|
||||||
Token string `toml:"token"`
|
Token string `toml:"token"`
|
||||||
AllowedUserIDs []int64 `toml:"allowed_user_ids"`
|
AllowedUserIDs []int64 `toml:"allowed_user_ids"`
|
||||||
|
WebBaseURL string `toml:"web_base_url"` // для deep-link «открыть в вебе» (опц.)
|
||||||
|
Proxy string `toml:"proxy"` // опц. HTTP-прокси для api.telegram.org
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTP — параметры веб-сервера.
|
// HTTP — параметры веб-сервера.
|
||||||
type HTTP struct {
|
type HTTP struct {
|
||||||
Listen string `toml:"listen"`
|
Listen string `toml:"listen"`
|
||||||
|
// TrustedSubnets — allowlist подсетей. ПОКА НЕ ПРИМЕНЯЕТСЯ: деплой только
|
||||||
|
// в локальную сеть без доступа из интернета, поэтому middleware отложено
|
||||||
|
// (см. architecture.md). Поле сохранено под будущую реализацию.
|
||||||
TrustedSubnets []string `toml:"trusted_subnets"`
|
TrustedSubnets []string `toml:"trusted_subnets"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ func NewRouter(d Deps) (http.Handler, error) {
|
|||||||
r.Post("/ui/downloads/{id}/refine", s.handleRefine)
|
r.Post("/ui/downloads/{id}/refine", s.handleRefine)
|
||||||
r.Post("/ui/downloads/{id}/type", s.handleSetType)
|
r.Post("/ui/downloads/{id}/type", s.handleSetType)
|
||||||
r.Post("/ui/downloads/{id}/ignore", s.handleIgnore)
|
r.Post("/ui/downloads/{id}/ignore", s.handleIgnore)
|
||||||
|
r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate)
|
||||||
|
r.Post("/ui/downloads/{id}/provider", s.handleSetProvider)
|
||||||
|
r.Post("/ui/downloads/{id}/nobase", s.handleNoBase)
|
||||||
r.Post("/ui/downloads/{id}/defer", s.handleDefer)
|
r.Post("/ui/downloads/{id}/defer", s.handleDefer)
|
||||||
r.Post("/ui/downloads/{id}/undo", s.handleUndo)
|
r.Post("/ui/downloads/{id}/undo", s.handleUndo)
|
||||||
|
|
||||||
@@ -262,6 +265,7 @@ func (s *server) apiCommand(w http.ResponseWriter, r *http.Request, cmd func(con
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := cmd(r.Context(), id); err != nil {
|
if err := cmd(r.Context(), id); err != nil {
|
||||||
|
s.deps.Logger.Warn("api command failed", "path", r.URL.Path, "id", id, "err", err)
|
||||||
writeJSON(w, http.StatusConflict, errJSON(err))
|
writeJSON(w, http.StatusConflict, errJSON(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package httpapi_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -182,14 +183,17 @@ func (e ingestErr) Error() string { return string(e) }
|
|||||||
// --- Ревью ---
|
// --- Ревью ---
|
||||||
|
|
||||||
type fakeReviewer struct {
|
type fakeReviewer struct {
|
||||||
data *worker.ReviewData
|
data *worker.ReviewData
|
||||||
applyErr error
|
applyErr error
|
||||||
refined map[int64]string
|
refined map[int64]string
|
||||||
typed map[int64]string
|
typed map[int64]string
|
||||||
ignored map[int64]string
|
ignored map[int64]string
|
||||||
applied []int64
|
chosen map[int64]int64
|
||||||
deferred []int64
|
providerSet map[int64]string
|
||||||
undone []int64
|
applied []int64
|
||||||
|
deferred []int64
|
||||||
|
undone []int64
|
||||||
|
cleared []int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeReviewer) ReviewData(_ context.Context, _ int64) (*worker.ReviewData, error) {
|
func (f *fakeReviewer) ReviewData(_ context.Context, _ int64) (*worker.ReviewData, error) {
|
||||||
@@ -231,6 +235,24 @@ func (f *fakeReviewer) Undo(_ context.Context, id int64) error {
|
|||||||
f.undone = append(f.undone, id)
|
f.undone = append(f.undone, id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID int64) error {
|
||||||
|
if f.chosen == nil {
|
||||||
|
f.chosen = map[int64]int64{}
|
||||||
|
}
|
||||||
|
f.chosen[id] = candidateID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeReviewer) SetProviderID(_ context.Context, id int64, provider, providerID string) error {
|
||||||
|
if f.providerSet == nil {
|
||||||
|
f.providerSet = map[int64]string{}
|
||||||
|
}
|
||||||
|
f.providerSet[id] = provider + ":" + providerID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeReviewer) ClearProvider(_ context.Context, id int64) error {
|
||||||
|
f.cleared = append(f.cleared, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func seriesReviewData() *worker.ReviewData {
|
func seriesReviewData() *worker.ReviewData {
|
||||||
s, e := 2, 1
|
s, e := 2, 1
|
||||||
@@ -248,6 +270,11 @@ func seriesReviewData() *worker.ReviewData {
|
|||||||
Preview: []layout.Link{
|
Preview: []layout.Link{
|
||||||
{Src: "Fargo/e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"},
|
{Src: "Fargo/e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"},
|
||||||
},
|
},
|
||||||
|
Candidates: []store.MetadataCandidate{
|
||||||
|
{ID: 10, Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
|
||||||
|
Year: sql.NullInt64{Int64: 2014, Valid: true}},
|
||||||
|
{ID: 11, Provider: "tmdb", ProviderID: "60622", Title: store.NullString("Fargo")},
|
||||||
|
},
|
||||||
Hints: []string{"второй сезон"},
|
Hints: []string{"второй сезон"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,13 +301,55 @@ func TestReviewRenders(t *testing.T) {
|
|||||||
t.Fatalf("status = %d", resp.StatusCode)
|
t.Fatalf("status = %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv",
|
for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv",
|
||||||
"Season 02", "Применить", "Уточнить"} {
|
"Season 02", "Применить", "Уточнить",
|
||||||
|
"База метаданных", "269613", "выбрать", "Без базы"} {
|
||||||
if !strings.Contains(string(body), want) {
|
if !strings.Contains(string(body), want) {
|
||||||
t.Errorf("страница ревью не содержит %q", want)
|
t.Errorf("страница ревью не содержит %q", want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestChooseCandidate(t *testing.T) {
|
||||||
|
rv := &fakeReviewer{data: seriesReviewData()}
|
||||||
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||||
|
Reader: &fakeReader{}, Reviewer: rv})
|
||||||
|
|
||||||
|
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/1/candidate",
|
||||||
|
map[string][]string{"candidate_id": {"10"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if rv.chosen[1] != 10 {
|
||||||
|
t.Errorf("ChooseCandidate получил %d", rv.chosen[1])
|
||||||
|
}
|
||||||
|
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/1") {
|
||||||
|
t.Errorf("Location = %q", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetProviderAndNoBase(t *testing.T) {
|
||||||
|
rv := &fakeReviewer{data: seriesReviewData()}
|
||||||
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||||
|
Reader: &fakeReader{}, Reviewer: rv})
|
||||||
|
cl := noRedirectClient()
|
||||||
|
|
||||||
|
if _, err := cl.PostForm(srv.URL+"/ui/downloads/1/provider",
|
||||||
|
map[string][]string{"provider": {"tvdb"}, "provider_id": {"269613"}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rv.providerSet[1] != "tvdb:269613" {
|
||||||
|
t.Errorf("SetProviderID получил %q", rv.providerSet[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := cl.Post(srv.URL+"/ui/downloads/1/nobase", "", nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rv.cleared) != 1 || rv.cleared[0] != 1 {
|
||||||
|
t.Errorf("ClearProvider = %v", rv.cleared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplyRedirectsToIndex(t *testing.T) {
|
func TestApplyRedirectsToIndex(t *testing.T) {
|
||||||
rv := &fakeReviewer{data: seriesReviewData()}
|
rv := &fakeReviewer{data: seriesReviewData()}
|
||||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ type Reviewer interface {
|
|||||||
IgnoreFile(ctx context.Context, id int64, src string) error
|
IgnoreFile(ctx context.Context, id int64, src string) error
|
||||||
Defer(ctx context.Context, id int64) error
|
Defer(ctx context.Context, id int64) error
|
||||||
Undo(ctx context.Context, id int64) error
|
Undo(ctx context.Context, id int64) error
|
||||||
|
ChooseCandidate(ctx context.Context, id, candidateID int64) error
|
||||||
|
SetProviderID(ctx context.Context, id int64, provider, providerID string) error
|
||||||
|
ClearProvider(ctx context.Context, id int64) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Представление страницы ревью ---
|
// --- Представление страницы ревью ---
|
||||||
@@ -44,6 +47,8 @@ type reviewView struct {
|
|||||||
Files []reviewFileView
|
Files []reviewFileView
|
||||||
Preview []string
|
Preview []string
|
||||||
HasPlan bool
|
HasPlan bool
|
||||||
|
NoBase bool // выбрано «без базы»
|
||||||
|
Candidates []candidateView
|
||||||
}
|
}
|
||||||
|
|
||||||
type reviewFileView struct {
|
type reviewFileView struct {
|
||||||
@@ -54,6 +59,15 @@ type reviewFileView struct {
|
|||||||
Ignored bool
|
Ignored bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type candidateView struct {
|
||||||
|
ID int64
|
||||||
|
Provider string
|
||||||
|
ProviderID string
|
||||||
|
Title string
|
||||||
|
Year int
|
||||||
|
Chosen bool
|
||||||
|
}
|
||||||
|
|
||||||
func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := pathID(r)
|
id, err := pathID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -87,9 +101,12 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
view.OriginalTitle = rd.Plan.OriginalTitle
|
view.OriginalTitle = rd.Plan.OriginalTitle
|
||||||
view.Year = rd.Plan.Year
|
view.Year = rd.Plan.Year
|
||||||
view.Reasons = rec.ReasonList()
|
view.Reasons = rec.ReasonList()
|
||||||
if rec.Provider.Valid && rec.Provider.String != "none" {
|
switch rd.Provider {
|
||||||
view.Provider = rec.Provider.String
|
case "", "none":
|
||||||
view.ProviderID = rec.ProviderID.String
|
view.NoBase = rd.Provider == "none"
|
||||||
|
default:
|
||||||
|
view.Provider = rd.Provider
|
||||||
|
view.ProviderID = rd.ProviderID
|
||||||
}
|
}
|
||||||
if rec.Confidence.Valid {
|
if rec.Confidence.Valid {
|
||||||
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
|
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
|
||||||
@@ -104,6 +121,16 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
view.HasPlan = len(rd.Plan.Files) > 0
|
view.HasPlan = len(rd.Plan.Files) > 0
|
||||||
|
for _, c := range rd.Candidates {
|
||||||
|
view.Candidates = append(view.Candidates, candidateView{
|
||||||
|
ID: c.ID,
|
||||||
|
Provider: c.Provider,
|
||||||
|
ProviderID: c.ProviderID,
|
||||||
|
Title: c.Title.String,
|
||||||
|
Year: int(c.Year.Int64),
|
||||||
|
Chosen: c.Chosen,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, l := range rd.Preview {
|
for _, l := range rd.Preview {
|
||||||
view.Preview = append(view.Preview, l.Dst)
|
view.Preview = append(view.Preview, l.Dst)
|
||||||
@@ -124,6 +151,7 @@ func (s *server) handleApply(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.deps.Reviewer.Apply(r.Context(), id); err != nil {
|
if err := s.deps.Reviewer.Apply(r.Context(), id); err != nil {
|
||||||
|
s.deps.Logger.Warn("review action failed", "action", "apply", "id", id, "err", err)
|
||||||
redirectReview(w, r, id, err.Error())
|
redirectReview(w, r, id, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -151,6 +179,32 @@ func (s *server) handleIgnore(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *server) handleChooseCandidate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
|
||||||
|
_ = r.ParseForm()
|
||||||
|
candidateID, err := strconv.ParseInt(r.PostForm.Get("candidate_id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errInvalidCandidate
|
||||||
|
}
|
||||||
|
return s.deps.Reviewer.ChooseCandidate(ctx, id, candidateID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleSetProvider(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
|
||||||
|
_ = r.ParseForm()
|
||||||
|
return s.deps.Reviewer.SetProviderID(ctx, id, r.PostForm.Get("provider"), r.PostForm.Get("provider_id"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleNoBase(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
|
||||||
|
return s.deps.Reviewer.ClearProvider(ctx, id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var errInvalidCandidate = errors.New("некорректный id кандидата")
|
||||||
|
|
||||||
func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := pathID(r)
|
id, err := pathID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -158,6 +212,7 @@ func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.deps.Reviewer.Defer(r.Context(), id); err != nil {
|
if err := s.deps.Reviewer.Defer(r.Context(), id); err != nil {
|
||||||
|
s.deps.Logger.Warn("review action failed", "action", "defer", "id", id, "err", err)
|
||||||
redirectReview(w, r, id, err.Error())
|
redirectReview(w, r, id, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -171,6 +226,7 @@ func (s *server) handleUndo(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.deps.Reviewer.Undo(r.Context(), id); err != nil {
|
if err := s.deps.Reviewer.Undo(r.Context(), id); err != nil {
|
||||||
|
s.deps.Logger.Warn("review action failed", "action", "undo", "id", id, "err", err)
|
||||||
redirectErr(w, r, err.Error())
|
redirectErr(w, r, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -186,6 +242,8 @@ func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(co
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := fn(r.Context(), id); err != nil {
|
if err := fn(r.Context(), id); err != nil {
|
||||||
|
s.deps.Logger.Warn("review action failed",
|
||||||
|
"action", r.URL.Path, "id", id, "err", err)
|
||||||
redirectReview(w, r, id, err.Error())
|
redirectReview(w, r, id, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
|||||||
SavePath: s.cfg.SavePath,
|
SavePath: s.cfg.SavePath,
|
||||||
})
|
})
|
||||||
if addErr != nil {
|
if addErr != nil {
|
||||||
|
s.log.Warn("ingest: qbittorrent add failed, marking download failed",
|
||||||
|
"download_id", id, "infohash", info.Infohash, "err", addErr)
|
||||||
// Задача уже в БД — помечаем failed, чтобы worker её не подхватил.
|
// Задача уже в БД — помечаем failed, чтобы worker её не подхватил.
|
||||||
if setErr := s.store.SetDownloadState(ctx, id, store.StateFailed, "qbit_add", addErr.Error()); setErr != nil {
|
if setErr := s.store.SetDownloadState(ctx, id, store.StateFailed, "qbit_add", addErr.Error()); setErr != nil {
|
||||||
s.log.Error("ingest: failed to mark download failed after qbit error",
|
s.log.Error("ingest: failed to mark download failed after qbit error",
|
||||||
|
|||||||
+102
-17
@@ -15,7 +15,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -49,7 +51,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 // для сериала
|
||||||
@@ -86,10 +88,12 @@ type Layouter struct {
|
|||||||
movies string
|
movies string
|
||||||
series string
|
series string
|
||||||
dirMode os.FileMode
|
dirMode os.FileMode
|
||||||
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New собирает раскладчик. Корни нормализуются (filepath.Clean).
|
// New собирает раскладчик. Корни нормализуются (filepath.Clean). logger nil →
|
||||||
func New(cfg Config) (*Layouter, error) {
|
// slog.Default().
|
||||||
|
func New(cfg Config, logger *slog.Logger) (*Layouter, error) {
|
||||||
if cfg.MoviesDir == "" || cfg.SeriesDir == "" {
|
if cfg.MoviesDir == "" || cfg.SeriesDir == "" {
|
||||||
return nil, fmt.Errorf("layout: movies/series dirs required")
|
return nil, fmt.Errorf("layout: movies/series dirs required")
|
||||||
}
|
}
|
||||||
@@ -97,10 +101,14 @@ func New(cfg Config) (*Layouter, error) {
|
|||||||
if mode == 0 {
|
if mode == 0 {
|
||||||
mode = 0o755
|
mode = 0o755
|
||||||
}
|
}
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
return &Layouter{
|
return &Layouter{
|
||||||
movies: filepath.Clean(cfg.MoviesDir),
|
movies: filepath.Clean(cfg.MoviesDir),
|
||||||
series: filepath.Clean(cfg.SeriesDir),
|
series: filepath.Clean(cfg.SeriesDir),
|
||||||
dirMode: mode,
|
dirMode: mode,
|
||||||
|
log: logger,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +120,7 @@ func (l *Layouter) root(t MediaType) (string, error) {
|
|||||||
case Series:
|
case Series:
|
||||||
return l.series, nil
|
return l.series, nil
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("layout: неизвестный тип %q", t)
|
return "", fmt.Errorf("layout: unknown type %q", t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,12 +160,12 @@ func (l *Layouter) BuildLinks(p Plan) ([]Link, error) {
|
|||||||
continue // роль не линкуется (extra/sample/ignore)
|
continue // роль не линкуется (extra/sample/ignore)
|
||||||
}
|
}
|
||||||
if !underRoot(root, dst) {
|
if !underRoot(root, dst) {
|
||||||
return nil, fmt.Errorf("layout: цель %q вне библиотеки %q (файл %q)", dst, root, f.Src)
|
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})
|
links = append(links, Link{Src: f.Src, Dst: dst, Kind: kind})
|
||||||
}
|
}
|
||||||
if len(links) == 0 {
|
if len(links) == 0 {
|
||||||
return nil, fmt.Errorf("layout: план не дал ни одной ссылки")
|
return nil, fmt.Errorf("layout: plan produced no links")
|
||||||
}
|
}
|
||||||
return links, nil
|
return links, nil
|
||||||
}
|
}
|
||||||
@@ -180,7 +188,7 @@ func (l *Layouter) seriesDst(root, folder, base string, f *PlanFile) (string, Ki
|
|||||||
return "", "", nil
|
return "", "", nil
|
||||||
}
|
}
|
||||||
if f.Season == nil || f.Episode == nil {
|
if f.Season == nil || f.Episode == nil {
|
||||||
return "", "", fmt.Errorf("layout: файл %q без season/episode", f.Src)
|
return "", "", fmt.Errorf("layout: file %q has no season/episode", f.Src)
|
||||||
}
|
}
|
||||||
episodeEnd := 0
|
episodeEnd := 0
|
||||||
if f.EpisodeEnd != nil {
|
if f.EpisodeEnd != nil {
|
||||||
@@ -203,7 +211,8 @@ func (l *Layouter) seriesDst(root, folder, base string, f *PlanFile) (string, Ki
|
|||||||
type LinkStatus string
|
type LinkStatus string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
StatusLinked LinkStatus = "linked" // ссылка создана
|
StatusLinked LinkStatus = "linked" // хардлинк создан
|
||||||
|
StatusCopied LinkStatus = "copied" // хардлинк невозможен — файл скопирован (фолбэк)
|
||||||
StatusExists LinkStatus = "exists" // уже была (тот же inode) — идемпотентно
|
StatusExists LinkStatus = "exists" // уже была (тот же inode) — идемпотентно
|
||||||
StatusCollision LinkStatus = "collision" // цель занята другим файлом
|
StatusCollision LinkStatus = "collision" // цель занята другим файлом
|
||||||
)
|
)
|
||||||
@@ -219,7 +228,8 @@ var ErrCollision = errors.New("layout: target collision")
|
|||||||
|
|
||||||
// Apply создаёт хардлинки по ссылкам. Идемпотентно: повтор после сбоя
|
// Apply создаёт хардлинки по ссылкам. Идемпотентно: повтор после сбоя
|
||||||
// доводит начатое. При коллизии (цель занята чужим файлом) возвращает
|
// доводит начатое. При коллизии (цель занята чужим файлом) возвращает
|
||||||
// ErrCollision, не перезаписывая. EXDEV (разные ФС) — явная ошибка.
|
// ErrCollision, не перезаписывая. Если хардлинк невозможен (разные ФС или ФС
|
||||||
|
// не поддерживает link) — фолбэк на копирование файла с предупреждением в лог.
|
||||||
func (l *Layouter) Apply(_ context.Context, links []Link) ([]Result, error) {
|
func (l *Layouter) Apply(_ context.Context, links []Link) ([]Result, error) {
|
||||||
results := make([]Result, 0, len(links))
|
results := make([]Result, 0, len(links))
|
||||||
for _, ln := range links {
|
for _, ln := range links {
|
||||||
@@ -228,23 +238,28 @@ func (l *Layouter) Apply(_ context.Context, links []Link) ([]Result, error) {
|
|||||||
root = l.series
|
root = l.series
|
||||||
}
|
}
|
||||||
if !underRoot(root, ln.Dst) {
|
if !underRoot(root, ln.Dst) {
|
||||||
return results, fmt.Errorf("layout: цель %q вне библиотек", 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 {
|
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)
|
return results, fmt.Errorf("layout: mkdir %q: %w", filepath.Dir(ln.Dst), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
status, err := linkOne(ln.Src, ln.Dst)
|
status, err := l.linkOne(ln.Src, ln.Dst)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
l.log.Error("layout: link failed",
|
||||||
|
"src", ln.Src, "dst", ln.Dst, "kind", ln.Kind, "err", err)
|
||||||
return results, err
|
return results, err
|
||||||
}
|
}
|
||||||
|
l.log.Debug("layout: link applied",
|
||||||
|
"src", ln.Src, "dst", ln.Dst, "kind", ln.Kind, "status", status)
|
||||||
results = append(results, Result{Link: ln, Status: status})
|
results = append(results, Result{Link: ln, Status: status})
|
||||||
}
|
}
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// linkOne создаёт одну ссылку, разбирая «уже существует».
|
// linkOne создаёт одну ссылку, разбирая «уже существует» и невозможность
|
||||||
func linkOne(src, dst string) (LinkStatus, error) {
|
// хардлинка (фолбэк на копирование).
|
||||||
|
func (l *Layouter) linkOne(src, dst string) (LinkStatus, error) {
|
||||||
err := os.Link(src, dst)
|
err := os.Link(src, dst)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return StatusLinked, nil
|
return StatusLinked, nil
|
||||||
@@ -257,14 +272,82 @@ func linkOne(src, dst string) (LinkStatus, error) {
|
|||||||
if same {
|
if same {
|
||||||
return StatusExists, nil // идемпотентно: тот же inode
|
return StatusExists, nil // идемпотентно: тот же inode
|
||||||
}
|
}
|
||||||
return StatusCollision, fmt.Errorf("%w: %q занят другим файлом", ErrCollision, dst)
|
return StatusCollision, fmt.Errorf("%w: %q is occupied by a different file", ErrCollision, dst)
|
||||||
}
|
}
|
||||||
if errors.Is(err, syscall.EXDEV) {
|
if hardlinkUnsupported(err) {
|
||||||
return "", fmt.Errorf("layout: hardlink через границу ФС (%q → %q): %w", src, dst, err)
|
// Хардлинк невозможен (граница ФС или ФС без поддержки link). Не валим
|
||||||
|
// раскладку — копируем файл и предупреждаем: диск дублируется, но
|
||||||
|
// задача доходит до конца. dst здесь заведомо отсутствует (иначе был бы
|
||||||
|
// fs.ErrExist выше).
|
||||||
|
l.log.Warn("layout: hardlink unsupported, falling back to file copy",
|
||||||
|
"src", src, "dst", dst, "err", 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)
|
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.
|
// sameFile сообщает, указывают ли src и dst на один inode.
|
||||||
func sameFile(src, dst string) (bool, error) {
|
func sameFile(src, dst string) (bool, error) {
|
||||||
si, err := os.Stat(src)
|
si, err := os.Stat(src)
|
||||||
@@ -289,15 +372,17 @@ func (l *Layouter) Undo(_ context.Context, links []Link) (int, error) {
|
|||||||
root = l.series
|
root = l.series
|
||||||
}
|
}
|
||||||
if !underRoot(root, ln.Dst) {
|
if !underRoot(root, ln.Dst) {
|
||||||
return removed, fmt.Errorf("layout: undo вне библиотеки: %q", ln.Dst)
|
return removed, fmt.Errorf("layout: undo outside library: %q", ln.Dst)
|
||||||
}
|
}
|
||||||
if err := os.Remove(ln.Dst); err != nil {
|
if err := os.Remove(ln.Dst); err != nil {
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
l.log.Error("layout: undo remove failed", "dst", ln.Dst, "err", err)
|
||||||
return removed, fmt.Errorf("layout: undo remove %q: %w", ln.Dst, err)
|
return removed, fmt.Errorf("layout: undo remove %q: %w", ln.Dst, err)
|
||||||
}
|
}
|
||||||
removed++
|
removed++
|
||||||
|
l.log.Debug("layout: link removed", "dst", ln.Dst)
|
||||||
pruneEmptyDirs(filepath.Dir(ln.Dst), root)
|
pruneEmptyDirs(filepath.Dir(ln.Dst), root)
|
||||||
}
|
}
|
||||||
return removed, nil
|
return removed, nil
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ func newFixture(t *testing.T) fixture {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
l, err := New(Config{MoviesDir: movies, SeriesDir: series})
|
l, err := New(Config{MoviesDir: movies, SeriesDir: series}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -251,6 +252,63 @@ func TestUndo_Idempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCopyFile_DuplicatesContentAndKeepsSource(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
src := filepath.Join(dir, "src.mkv")
|
||||||
|
dst := filepath.Join(dir, "sub", "dst.mkv")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(src, []byte("payload"), 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := copyFile(src, dst); err != nil {
|
||||||
|
t.Fatalf("copyFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := os.ReadFile(dst)
|
||||||
|
if err != nil || string(got) != "payload" {
|
||||||
|
t.Fatalf("dst content = %q, err = %v", got, err)
|
||||||
|
}
|
||||||
|
// Источник цел и это отдельный inode (копия, не хардлинк).
|
||||||
|
si, _ := os.Stat(src)
|
||||||
|
di, _ := os.Stat(dst)
|
||||||
|
if os.SameFile(si, di) {
|
||||||
|
t.Error("dst must be a distinct copy, not a hardlink")
|
||||||
|
}
|
||||||
|
if di.Mode().Perm() != 0o640 {
|
||||||
|
t.Errorf("dst mode = %v, want source mode 0640", di.Mode().Perm())
|
||||||
|
}
|
||||||
|
// Временные файлы копирования подчищены.
|
||||||
|
entries, _ := os.ReadDir(filepath.Dir(dst))
|
||||||
|
for _, e := range entries {
|
||||||
|
if len(e.Name()) >= 14 && e.Name()[:14] == ".jellybit-copy" {
|
||||||
|
t.Errorf("leftover temp file: %s", e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHardlinkUnsupported(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{syscall.EXDEV, true},
|
||||||
|
{syscall.ENOTSUP, true},
|
||||||
|
{syscall.EOPNOTSUPP, true},
|
||||||
|
{syscall.EPERM, true},
|
||||||
|
{syscall.ENOENT, false},
|
||||||
|
{os.ErrExist, false},
|
||||||
|
{errors.New("random"), false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := hardlinkUnsupported(tc.err); got != tc.want {
|
||||||
|
t.Errorf("hardlinkUnsupported(%v) = %v, want %v", tc.err, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUndo_RefusesOutsideLibrary(t *testing.T) {
|
func TestUndo_RefusesOutsideLibrary(t *testing.T) {
|
||||||
f := newFixture(t)
|
f := newFixture(t)
|
||||||
outside := filepath.Join(f.downloads, "victim.mkv")
|
outside := filepath.Join(f.downloads, "victim.mkv")
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func sanitizeComponent(s string) string {
|
|||||||
func titleYear(title string, year int) (string, error) {
|
func titleYear(title string, year int) (string, error) {
|
||||||
t := sanitizeComponent(title)
|
t := sanitizeComponent(title)
|
||||||
if t == "" {
|
if t == "" {
|
||||||
return "", fmt.Errorf("layout: пустое название после санитизации (%q)", title)
|
return "", fmt.Errorf("layout: empty title after sanitization (%q)", title)
|
||||||
}
|
}
|
||||||
if year > 0 {
|
if year > 0 {
|
||||||
return fmt.Sprintf("%s (%d)", t, year), nil
|
return fmt.Sprintf("%s (%d)", t, year), nil
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func TestIntegration_OpenAICompat(t *testing.T) {
|
|||||||
APIKey: key,
|
APIKey: key,
|
||||||
Model: model,
|
Model: model,
|
||||||
Timeout: 90 * time.Second,
|
Timeout: 90 * time.Second,
|
||||||
})
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New: %v", err)
|
t.Fatalf("New: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-4
@@ -14,6 +14,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -74,13 +75,16 @@ type Config struct {
|
|||||||
// ErrUnknownType — запрошенный [llm].type не поддерживается.
|
// ErrUnknownType — запрошенный [llm].type не поддерживается.
|
||||||
var ErrUnknownType = errors.New("llm: unknown provider type")
|
var ErrUnknownType = errors.New("llm: unknown provider type")
|
||||||
|
|
||||||
// New собирает провайдер по дискриминатору cfg.Type.
|
// New собирает провайдер по дискриминатору cfg.Type. logger nil → slog.Default().
|
||||||
func New(cfg Config) (Provider, error) {
|
func New(cfg Config, logger *slog.Logger) (Provider, error) {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
switch cfg.Type {
|
switch cfg.Type {
|
||||||
case "openai-compat":
|
case "openai-compat":
|
||||||
return newOpenAICompat(cfg)
|
return newOpenAICompat(cfg, logger)
|
||||||
case "":
|
case "":
|
||||||
return nil, fmt.Errorf("%w: %q (укажите [llm].type)", ErrUnknownType, cfg.Type)
|
return nil, fmt.Errorf("%w: %q (set [llm].type)", ErrUnknownType, cfg.Type)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("%w: %q", ErrUnknownType, cfg.Type)
|
return nil, fmt.Errorf("%w: %q", ErrUnknownType, cfg.Type)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func newTestProvider(t *testing.T, baseURL, apiKey string) *openAICompat {
|
|||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
APIKey: apiKey,
|
APIKey: apiKey,
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
})
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("newOpenAICompat: %v", err)
|
t.Fatalf("newOpenAICompat: %v", err)
|
||||||
}
|
}
|
||||||
@@ -215,22 +215,22 @@ func TestComplete_EmptyMessages(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNew_UnknownType(t *testing.T) {
|
func TestNew_UnknownType(t *testing.T) {
|
||||||
if _, err := New(Config{Type: "anthropic", Model: "x", BaseURL: "http://x"}); err == nil {
|
if _, err := New(Config{Type: "anthropic", Model: "x", BaseURL: "http://x"}, nil); err == nil {
|
||||||
t.Fatal("want error for unknown type")
|
t.Fatal("want error for unknown type")
|
||||||
}
|
}
|
||||||
if _, err := New(Config{Type: ""}); err == nil {
|
if _, err := New(Config{Type: ""}, nil); err == nil {
|
||||||
t.Fatal("want error for empty type")
|
t.Fatal("want error for empty type")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNew_OpenAICompatValidation(t *testing.T) {
|
func TestNew_OpenAICompatValidation(t *testing.T) {
|
||||||
if _, err := New(Config{Type: "openai-compat", Model: "x"}); err == nil {
|
if _, err := New(Config{Type: "openai-compat", Model: "x"}, nil); err == nil {
|
||||||
t.Fatal("want error for empty base_url")
|
t.Fatal("want error for empty base_url")
|
||||||
}
|
}
|
||||||
if _, err := New(Config{Type: "openai-compat", BaseURL: "http://x"}); err == nil {
|
if _, err := New(Config{Type: "openai-compat", BaseURL: "http://x"}, nil); err == nil {
|
||||||
t.Fatal("want error for empty model")
|
t.Fatal("want error for empty model")
|
||||||
}
|
}
|
||||||
if _, err := New(Config{Type: "openai-compat", BaseURL: "http://x", Model: "m"}); err != nil {
|
if _, err := New(Config{Type: "openai-compat", BaseURL: "http://x", Model: "m"}, nil); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-1
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -26,10 +27,11 @@ type openAICompat struct {
|
|||||||
apiKey string
|
apiKey string
|
||||||
model string
|
model string
|
||||||
retryWait time.Duration // базовая пауза между ретраями (0 в тестах)
|
retryWait time.Duration // базовая пауза между ретраями (0 в тестах)
|
||||||
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// newOpenAICompat собирает клиент из конфига.
|
// newOpenAICompat собирает клиент из конфига.
|
||||||
func newOpenAICompat(cfg Config) (*openAICompat, error) {
|
func newOpenAICompat(cfg Config, logger *slog.Logger) (*openAICompat, error) {
|
||||||
if cfg.BaseURL == "" {
|
if cfg.BaseURL == "" {
|
||||||
return nil, fmt.Errorf("llm: empty base_url")
|
return nil, fmt.Errorf("llm: empty base_url")
|
||||||
}
|
}
|
||||||
@@ -51,12 +53,16 @@ func newOpenAICompat(cfg Config) (*openAICompat, error) {
|
|||||||
transport = &http.Transport{Proxy: http.ProxyURL(proxyURL)}
|
transport = &http.Transport{Proxy: http.ProxyURL(proxyURL)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
return &openAICompat{
|
return &openAICompat{
|
||||||
endpoint: strings.TrimRight(cfg.BaseURL, "/") + "/chat/completions",
|
endpoint: strings.TrimRight(cfg.BaseURL, "/") + "/chat/completions",
|
||||||
hc: &http.Client{Timeout: timeout, Transport: transport},
|
hc: &http.Client{Timeout: timeout, Transport: transport},
|
||||||
apiKey: cfg.APIKey,
|
apiKey: cfg.APIKey,
|
||||||
model: cfg.Model,
|
model: cfg.Model,
|
||||||
retryWait: baseRetryWait,
|
retryWait: baseRetryWait,
|
||||||
|
log: logger,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,15 +125,30 @@ func (c *openAICompat) Complete(ctx context.Context, req Request) (Response, err
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.log.Debug("llm: request",
|
||||||
|
"endpoint", c.endpoint, "model", c.model,
|
||||||
|
"attempt", attempt, "max_attempts", maxAttempts)
|
||||||
|
start := time.Now()
|
||||||
resp, retryable, err := c.do(ctx, body)
|
resp, retryable, err := c.do(ctx, body)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
c.log.Debug("llm: response ok",
|
||||||
|
"model", resp.Model, "attempt", attempt,
|
||||||
|
"duration", time.Since(start),
|
||||||
|
"total_tokens", resp.Usage.TotalTokens, "cost", resp.Usage.Cost)
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
lastErr = err
|
lastErr = err
|
||||||
if !retryable {
|
if !retryable {
|
||||||
|
c.log.Error("llm: request failed (non-retryable)",
|
||||||
|
"model", c.model, "attempt", attempt, "duration", time.Since(start), "err", err)
|
||||||
return Response{}, err
|
return Response{}, err
|
||||||
}
|
}
|
||||||
|
c.log.Warn("llm: request failed, will retry",
|
||||||
|
"model", c.model, "attempt", attempt, "max_attempts", maxAttempts,
|
||||||
|
"duration", time.Since(start), "err", err)
|
||||||
}
|
}
|
||||||
|
c.log.Error("llm: all attempts exhausted",
|
||||||
|
"model", c.model, "max_attempts", maxAttempts, "err", lastErr)
|
||||||
return Response{}, fmt.Errorf("llm: exhausted %d attempts: %w", maxAttempts, lastErr)
|
return Response{}, fmt.Errorf("llm: exhausted %d attempts: %w", maxAttempts, lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
@@ -24,7 +25,12 @@ func newHTTPClient(proxy string, timeout time.Duration) (*http.Client, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("metadata: parse proxy %q: %w", proxy, err)
|
return nil, fmt.Errorf("metadata: parse proxy %q: %w", proxy, err)
|
||||||
}
|
}
|
||||||
transport = &http.Transport{Proxy: http.ProxyURL(u)}
|
// Клонируем дефолтный транспорт (dial/TLS-таймауты, keep-alive), а не
|
||||||
|
// собираем голый — иначе при живом-но-залипшем прокси полагались бы
|
||||||
|
// только на общий Client.Timeout. Он остаётся верхней границей запроса.
|
||||||
|
t := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
t.Proxy = http.ProxyURL(u)
|
||||||
|
transport = t
|
||||||
}
|
}
|
||||||
return &http.Client{Timeout: timeout, Transport: transport}, nil
|
return &http.Client{Timeout: timeout, Transport: transport}, nil
|
||||||
}
|
}
|
||||||
@@ -33,7 +39,7 @@ const maxBody = 4 << 20 // 4 MiB — потолок на тело ответа
|
|||||||
|
|
||||||
// getJSON выполняет GET и декодирует JSON-ответ в out. headers — опц.
|
// getJSON выполняет GET и декодирует JSON-ответ в out. headers — опц.
|
||||||
// дополнительные заголовки (напр. Authorization).
|
// дополнительные заголовки (напр. Authorization).
|
||||||
func getJSON(ctx context.Context, hc *http.Client, rawURL string, headers map[string]string, out any) error {
|
func getJSON(ctx context.Context, hc *http.Client, log *slog.Logger, rawURL string, headers map[string]string, out any) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("metadata: build request: %w", err)
|
return fmt.Errorf("metadata: build request: %w", err)
|
||||||
@@ -42,11 +48,11 @@ func getJSON(ctx context.Context, hc *http.Client, rawURL string, headers map[st
|
|||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
return doJSON(hc, req, out)
|
return doJSON(hc, log, req, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// postJSON выполняет POST с JSON-телом и декодирует ответ.
|
// postJSON выполняет POST с JSON-телом и декодирует ответ.
|
||||||
func postJSON(ctx context.Context, hc *http.Client, rawURL string, body, out any) error {
|
func postJSON(ctx context.Context, hc *http.Client, log *slog.Logger, rawURL string, body, out any) error {
|
||||||
payload, err := json.Marshal(body)
|
payload, err := json.Marshal(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("metadata: marshal body: %w", err)
|
return fmt.Errorf("metadata: marshal body: %w", err)
|
||||||
@@ -57,26 +63,46 @@ func postJSON(ctx context.Context, hc *http.Client, rawURL string, body, out any
|
|||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
return doJSON(hc, req, out)
|
return doJSON(hc, log, req, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func doJSON(hc *http.Client, req *http.Request, out any) error {
|
// doJSON выполняет запрос и декодирует ответ, логируя исход. В лог идут только
|
||||||
|
// host и path (без query) — у TMDB api_key передаётся query-параметром, его
|
||||||
|
// нельзя светить в логах.
|
||||||
|
func doJSON(hc *http.Client, log *slog.Logger, req *http.Request, out any) error {
|
||||||
|
if log == nil {
|
||||||
|
log = slog.Default()
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
resp, err := hc.Do(req)
|
resp, err := hc.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Warn("metadata: request failed",
|
||||||
|
"method", req.Method, "host", req.URL.Host, "path", req.URL.Path,
|
||||||
|
"duration", time.Since(start), "err", err)
|
||||||
return fmt.Errorf("metadata: request: %w", err)
|
return fmt.Errorf("metadata: request: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Warn("metadata: read body failed",
|
||||||
|
"host", req.URL.Host, "path", req.URL.Path, "err", err)
|
||||||
return fmt.Errorf("metadata: read body: %w", err)
|
return fmt.Errorf("metadata: read body: %w", err)
|
||||||
}
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
log.Warn("metadata: non-ok status",
|
||||||
|
"method", req.Method, "host", req.URL.Host, "path", req.URL.Path,
|
||||||
|
"status", resp.StatusCode, "duration", time.Since(start))
|
||||||
return fmt.Errorf("metadata: status %d: %s", resp.StatusCode, snippet(raw))
|
return fmt.Errorf("metadata: status %d: %s", resp.StatusCode, snippet(raw))
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(raw, out); err != nil {
|
if err := json.Unmarshal(raw, out); err != nil {
|
||||||
|
log.Warn("metadata: decode failed",
|
||||||
|
"host", req.URL.Host, "path", req.URL.Path, "err", err)
|
||||||
return fmt.Errorf("metadata: decode: %w (body: %s)", err, snippet(raw))
|
return fmt.Errorf("metadata: decode: %w (body: %s)", err, snippet(raw))
|
||||||
}
|
}
|
||||||
|
log.Debug("metadata: request ok",
|
||||||
|
"method", req.Method, "host", req.URL.Host, "path", req.URL.Path,
|
||||||
|
"duration", time.Since(start))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,45 @@ import (
|
|||||||
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestIntegration_TVMaze бьётся в реальный TVMaze (без ключа). Сетевой, по
|
||||||
|
// умолчанию пропускается; включается флагом:
|
||||||
|
//
|
||||||
|
// JELLYBIT_LIVE=1 go test ./internal/metadata/ -run Integration -v
|
||||||
|
func TestIntegration_TVMaze(t *testing.T) {
|
||||||
|
if os.Getenv("JELLYBIT_LIVE") == "" {
|
||||||
|
t.Skip("set JELLYBIT_LIVE=1 to run network tests")
|
||||||
|
}
|
||||||
|
c, err := metadata.NewTVMaze(metadata.TVMazeConfig{Timeout: 20 * time.Second}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewTVMaze: %v", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cands, err := c.Search(ctx, metadata.Query{Type: metadata.Series, Title: "Fargo", Year: 2014})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search: %v", err)
|
||||||
|
}
|
||||||
|
if len(cands) == 0 {
|
||||||
|
t.Fatal("ожидался хотя бы один кандидат")
|
||||||
|
}
|
||||||
|
top := cands[0]
|
||||||
|
t.Logf("top: id=%s title=%q year=%d tag=%s/%s",
|
||||||
|
top.ID, top.Title, top.Year, top.TagProvider, top.TagID)
|
||||||
|
if top.TagProvider != "tvdb" || top.TagID == "" {
|
||||||
|
t.Errorf("ожидался TVDB-тег из externals, got %s/%s", top.TagProvider, top.TagID)
|
||||||
|
}
|
||||||
|
|
||||||
|
counts, err := c.SeasonEpisodeCounts(ctx, top.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SeasonEpisodeCounts: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("season counts: %v", counts)
|
||||||
|
if counts[1] == 0 {
|
||||||
|
t.Error("ожидались серии в первом сезоне")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestIntegration_TVDB бьётся в реальный TheTVDB v4. По умолчанию
|
// TestIntegration_TVDB бьётся в реальный TheTVDB v4. По умолчанию
|
||||||
// пропускается; включается ключом:
|
// пропускается; включается ключом:
|
||||||
//
|
//
|
||||||
@@ -18,7 +57,7 @@ func TestIntegration_TVDB(t *testing.T) {
|
|||||||
if key == "" {
|
if key == "" {
|
||||||
t.Skip("set TVDB_API_KEY to run")
|
t.Skip("set TVDB_API_KEY to run")
|
||||||
}
|
}
|
||||||
c, err := metadata.NewTVDB(metadata.TVDBConfig{APIKey: key, Timeout: 20 * time.Second})
|
c, err := metadata.NewTVDB(metadata.TVDBConfig{APIKey: key, Timeout: 20 * time.Second}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewTVDB: %v", err)
|
t.Fatalf("NewTVDB: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,12 +25,19 @@ type Query struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Candidate — результат поиска: официальный id и каноническое имя.
|
// Candidate — результат поиска: официальный id и каноническое имя.
|
||||||
|
//
|
||||||
|
// ID — нативный id провайдера (по нему запрашиваются SeasonEpisodeCounts).
|
||||||
|
// TagProvider/TagID — опц. внешний id для имени папки Jellyfin: напр. TVMaze
|
||||||
|
// ищет без ключа, но отдаёт TVDB/IMDb-id во внешних ссылках, и тег ставим
|
||||||
|
// привычный ([tvdbid-…]). Пусто → тег берётся из Provider/ID.
|
||||||
type Candidate struct {
|
type Candidate struct {
|
||||||
Provider string // "tmdb" | "tvdb"
|
Provider string // "tmdb" | "tvdb" | "tvmaze"
|
||||||
ID string
|
ID string
|
||||||
Title string
|
Title string
|
||||||
OriginalTitle string
|
OriginalTitle string
|
||||||
Year int
|
Year int
|
||||||
|
TagProvider string // напр. "tvdb"/"imdb" (опц.)
|
||||||
|
TagID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider — одна база метаданных.
|
// Provider — одна база метаданных.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package metadata
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -25,10 +26,11 @@ type TMDB struct {
|
|||||||
apiKey string
|
apiKey string
|
||||||
baseURL string
|
baseURL string
|
||||||
hc *http.Client
|
hc *http.Client
|
||||||
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTMDB собирает клиент TMDB.
|
// NewTMDB собирает клиент TMDB. logger nil → slog.Default().
|
||||||
func NewTMDB(cfg TMDBConfig) (*TMDB, error) {
|
func NewTMDB(cfg TMDBConfig, logger *slog.Logger) (*TMDB, error) {
|
||||||
if cfg.APIKey == "" {
|
if cfg.APIKey == "" {
|
||||||
return nil, fmt.Errorf("metadata: tmdb api_key required")
|
return nil, fmt.Errorf("metadata: tmdb api_key required")
|
||||||
}
|
}
|
||||||
@@ -40,7 +42,10 @@ func NewTMDB(cfg TMDBConfig) (*TMDB, error) {
|
|||||||
if base == "" {
|
if base == "" {
|
||||||
base = tmdbDefaultBaseURL
|
base = tmdbDefaultBaseURL
|
||||||
}
|
}
|
||||||
return &TMDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), hc: hc}, nil
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &TMDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), hc: hc, log: logger}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TMDB) Name() string { return "tmdb" }
|
func (t *TMDB) Name() string { return "tmdb" }
|
||||||
@@ -73,13 +78,15 @@ func (t *TMDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
|||||||
params.Set("first_air_date_year", strconv.Itoa(q.Year))
|
params.Set("first_air_date_year", strconv.Itoa(q.Year))
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("metadata: tmdb: неизвестный тип %q", q.Type)
|
return nil, fmt.Errorf("metadata: tmdb: unknown type %q", q.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.log.Debug("tmdb: search", "type", q.Type, "title", q.Title, "year", q.Year)
|
||||||
var resp tmdbSearchResp
|
var resp tmdbSearchResp
|
||||||
if err := getJSON(ctx, t.hc, t.baseURL+path+"?"+params.Encode(), nil, &resp); err != nil {
|
if err := getJSON(ctx, t.hc, t.log, t.baseURL+path+"?"+params.Encode(), nil, &resp); err != nil {
|
||||||
return nil, fmt.Errorf("tmdb search: %w", err)
|
return nil, fmt.Errorf("tmdb search: %w", err)
|
||||||
}
|
}
|
||||||
|
t.log.Debug("tmdb: search done", "title", q.Title, "results", len(resp.Results))
|
||||||
|
|
||||||
out := make([]Candidate, 0, len(resp.Results))
|
out := make([]Candidate, 0, len(resp.Results))
|
||||||
for _, r := range resp.Results {
|
for _, r := range resp.Results {
|
||||||
@@ -109,7 +116,7 @@ type tmdbTVResp struct {
|
|||||||
func (t *TMDB) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) {
|
func (t *TMDB) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) {
|
||||||
params := url.Values{"api_key": {t.apiKey}}
|
params := url.Values{"api_key": {t.apiKey}}
|
||||||
var resp tmdbTVResp
|
var resp tmdbTVResp
|
||||||
if err := getJSON(ctx, t.hc, t.baseURL+"/tv/"+url.PathEscape(id)+"?"+params.Encode(), nil, &resp); err != nil {
|
if err := getJSON(ctx, t.hc, t.log, t.baseURL+"/tv/"+url.PathEscape(id)+"?"+params.Encode(), nil, &resp); err != nil {
|
||||||
return nil, fmt.Errorf("tmdb tv %s: %w", id, err)
|
return nil, fmt.Errorf("tmdb tv %s: %w", id, err)
|
||||||
}
|
}
|
||||||
out := make(map[int]int, len(resp.Seasons))
|
out := make(map[int]int, len(resp.Seasons))
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
func newTMDB(t *testing.T, url string) *TMDB {
|
func newTMDB(t *testing.T, url string) *TMDB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
c, err := NewTMDB(TMDBConfig{APIKey: "k", BaseURL: url})
|
c, err := NewTMDB(TMDBConfig{APIKey: "k", BaseURL: url}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewTMDB: %v", err)
|
t.Fatalf("NewTMDB: %v", err)
|
||||||
}
|
}
|
||||||
@@ -103,7 +103,7 @@ func TestTMDB_ErrorStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewTMDB_RequiresKey(t *testing.T) {
|
func TestNewTMDB_RequiresKey(t *testing.T) {
|
||||||
if _, err := NewTMDB(TMDBConfig{}); err == nil {
|
if _, err := NewTMDB(TMDBConfig{}, nil); err == nil {
|
||||||
t.Fatal("want error without api_key")
|
t.Fatal("want error without api_key")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -30,13 +31,14 @@ type TVDB struct {
|
|||||||
apiKey string
|
apiKey string
|
||||||
baseURL string
|
baseURL string
|
||||||
hc *http.Client
|
hc *http.Client
|
||||||
|
log *slog.Logger
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
token string
|
token string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTVDB собирает клиент TVDB.
|
// NewTVDB собирает клиент TVDB. logger nil → slog.Default().
|
||||||
func NewTVDB(cfg TVDBConfig) (*TVDB, error) {
|
func NewTVDB(cfg TVDBConfig, logger *slog.Logger) (*TVDB, error) {
|
||||||
if cfg.APIKey == "" {
|
if cfg.APIKey == "" {
|
||||||
return nil, fmt.Errorf("metadata: tvdb api_key required")
|
return nil, fmt.Errorf("metadata: tvdb api_key required")
|
||||||
}
|
}
|
||||||
@@ -48,7 +50,10 @@ func NewTVDB(cfg TVDBConfig) (*TVDB, error) {
|
|||||||
if base == "" {
|
if base == "" {
|
||||||
base = tvdbDefaultBaseURL
|
base = tvdbDefaultBaseURL
|
||||||
}
|
}
|
||||||
return &TVDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), hc: hc}, nil
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &TVDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), hc: hc, log: logger}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TVDB) Name() string { return "tvdb" }
|
func (t *TVDB) Name() string { return "tvdb" }
|
||||||
@@ -65,7 +70,8 @@ func (t *TVDB) login(ctx context.Context) (string, error) {
|
|||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
if err := postJSON(ctx, t.hc, t.baseURL+"/login",
|
t.log.Debug("tvdb: login (fetching bearer token)")
|
||||||
|
if err := postJSON(ctx, t.hc, t.log, t.baseURL+"/login",
|
||||||
map[string]string{"apikey": t.apiKey}, &resp); err != nil {
|
map[string]string{"apikey": t.apiKey}, &resp); err != nil {
|
||||||
return "", fmt.Errorf("tvdb login: %w", err)
|
return "", fmt.Errorf("tvdb login: %w", err)
|
||||||
}
|
}
|
||||||
@@ -87,6 +93,7 @@ func (t *TVDB) get(ctx context.Context, path string, out any) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if status == http.StatusUnauthorized {
|
if status == http.StatusUnauthorized {
|
||||||
|
t.log.Warn("tvdb: token expired, re-login", "path", path)
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
t.token = "" // сбрасываем протухший токен
|
t.token = "" // сбрасываем протухший токен
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
@@ -113,15 +120,21 @@ func (t *TVDB) rawGet(ctx context.Context, path, token string) (int, []byte, err
|
|||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
start := time.Now()
|
||||||
resp, err := t.hc.Do(req)
|
resp, err := t.hc.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
t.log.Warn("tvdb: request failed",
|
||||||
|
"host", req.URL.Host, "path", req.URL.Path, "duration", time.Since(start), "err", err)
|
||||||
return 0, nil, fmt.Errorf("tvdb: request: %w", err)
|
return 0, nil, fmt.Errorf("tvdb: request: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
t.log.Warn("tvdb: read body failed", "host", req.URL.Host, "path", req.URL.Path, "err", err)
|
||||||
return 0, nil, fmt.Errorf("tvdb: read body: %w", err)
|
return 0, nil, fmt.Errorf("tvdb: read body: %w", err)
|
||||||
}
|
}
|
||||||
|
t.log.Debug("tvdb: request done",
|
||||||
|
"host", req.URL.Host, "path", req.URL.Path, "status", resp.StatusCode, "duration", time.Since(start))
|
||||||
return resp.StatusCode, raw, nil
|
return resp.StatusCode, raw, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,10 +156,12 @@ func (t *TVDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
|||||||
if q.Year > 0 {
|
if q.Year > 0 {
|
||||||
params.Set("year", strconv.Itoa(q.Year))
|
params.Set("year", strconv.Itoa(q.Year))
|
||||||
}
|
}
|
||||||
|
t.log.Debug("tvdb: search", "type", q.Type, "title", q.Title, "year", q.Year)
|
||||||
var resp tvdbSearchResp
|
var resp tvdbSearchResp
|
||||||
if err := t.get(ctx, "/search?"+params.Encode(), &resp); err != nil {
|
if err := t.get(ctx, "/search?"+params.Encode(), &resp); err != nil {
|
||||||
return nil, fmt.Errorf("tvdb search: %w", err)
|
return nil, fmt.Errorf("tvdb search: %w", err)
|
||||||
}
|
}
|
||||||
|
t.log.Debug("tvdb: search done", "title", q.Title, "results", len(resp.Data))
|
||||||
out := make([]Candidate, 0, len(resp.Data))
|
out := make([]Candidate, 0, len(resp.Data))
|
||||||
for _, r := range resp.Data {
|
for _, r := range resp.Data {
|
||||||
if r.TVDBID == "" {
|
if r.TVDBID == "" {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func fakeTVDB(t *testing.T, logins *atomic.Int32) *httptest.Server {
|
|||||||
|
|
||||||
func newTVDB(t *testing.T, url string) *TVDB {
|
func newTVDB(t *testing.T, url string) *TVDB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
c, err := NewTVDB(TVDBConfig{APIKey: "k", BaseURL: url})
|
c, err := NewTVDB(TVDBConfig{APIKey: "k", BaseURL: url}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewTVDB: %v", err)
|
t.Fatalf("NewTVDB: %v", err)
|
||||||
}
|
}
|
||||||
@@ -126,7 +126,7 @@ func TestTVDB_ReloginOn401(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewTVDB_RequiresKey(t *testing.T) {
|
func TestNewTVDB_RequiresKey(t *testing.T) {
|
||||||
if _, err := NewTVDB(TVDBConfig{}); err == nil {
|
if _, err := NewTVDB(TVDBConfig{}, nil); err == nil {
|
||||||
t.Fatal("want error without api_key")
|
t.Fatal("want error without api_key")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package metadata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tvmazeDefaultBaseURL = "https://api.tvmaze.com"
|
||||||
|
|
||||||
|
// TVMazeConfig — настройки клиента TVMaze.
|
||||||
|
type TVMazeConfig struct {
|
||||||
|
Proxy string
|
||||||
|
Timeout time.Duration
|
||||||
|
BaseURL string // пусто → api.tvmaze.com; задаётся в тестах
|
||||||
|
}
|
||||||
|
|
||||||
|
// TVMaze — клиент TVMaze. Открытое API без ключа (лимит ~20 запросов/10с).
|
||||||
|
// Покрывает только сериалы; для фильмов поиск возвращает пусто. В externals
|
||||||
|
// отдаёт TVDB/IMDb-id, который используем как тег папки Jellyfin.
|
||||||
|
type TVMaze struct {
|
||||||
|
baseURL string
|
||||||
|
hc *http.Client
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTVMaze собирает клиент TVMaze (ключ не нужен). logger nil → slog.Default().
|
||||||
|
func NewTVMaze(cfg TVMazeConfig, logger *slog.Logger) (*TVMaze, error) {
|
||||||
|
hc, err := newHTTPClient(cfg.Proxy, cfg.Timeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
base := cfg.BaseURL
|
||||||
|
if base == "" {
|
||||||
|
base = tvmazeDefaultBaseURL
|
||||||
|
}
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &TVMaze{baseURL: strings.TrimRight(base, "/"), hc: hc, log: logger}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TVMaze) Name() string { return "tvmaze" }
|
||||||
|
|
||||||
|
type tvmazeShow struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Premiered string `json:"premiered"`
|
||||||
|
Externals struct {
|
||||||
|
TheTVDB int `json:"thetvdb"`
|
||||||
|
IMDb string `json:"imdb"`
|
||||||
|
} `json:"externals"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search ищет сериал по названию. Фильмы TVMaze не покрывает — для них
|
||||||
|
// возвращает пусто. Год не сужаем в запросе (TVMaze не фильтрует по году),
|
||||||
|
// отбор по году делает вызывающий.
|
||||||
|
func (t *TVMaze) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
||||||
|
if q.Type != Series {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var resp []struct {
|
||||||
|
Show tvmazeShow `json:"show"`
|
||||||
|
}
|
||||||
|
rawURL := t.baseURL + "/search/shows?q=" + url.QueryEscape(q.Title)
|
||||||
|
t.log.Debug("tvmaze: search", "title", q.Title)
|
||||||
|
if err := getJSON(ctx, t.hc, t.log, rawURL, nil, &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("tvmaze search: %w", err)
|
||||||
|
}
|
||||||
|
t.log.Debug("tvmaze: search done", "title", q.Title, "results", len(resp))
|
||||||
|
|
||||||
|
out := make([]Candidate, 0, len(resp))
|
||||||
|
for _, r := range resp {
|
||||||
|
s := r.Show
|
||||||
|
c := Candidate{
|
||||||
|
Provider: "tvmaze",
|
||||||
|
ID: strconv.Itoa(s.ID),
|
||||||
|
Title: s.Name,
|
||||||
|
Year: yearOf(s.Premiered),
|
||||||
|
}
|
||||||
|
// Тег папки — привычный TVDB-id, если есть; иначе IMDb.
|
||||||
|
switch {
|
||||||
|
case s.Externals.TheTVDB != 0:
|
||||||
|
c.TagProvider, c.TagID = "tvdb", strconv.Itoa(s.Externals.TheTVDB)
|
||||||
|
case s.Externals.IMDb != "":
|
||||||
|
c.TagProvider, c.TagID = "imdb", s.Externals.IMDb
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type tvmazeEpisode struct {
|
||||||
|
Season int `json:"season"`
|
||||||
|
Number int `json:"number"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeasonEpisodeCounts считает число серий по сезонам (нативный id TVMaze).
|
||||||
|
func (t *TVMaze) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) {
|
||||||
|
var eps []tvmazeEpisode
|
||||||
|
rawURL := t.baseURL + "/shows/" + url.PathEscape(id) + "/episodes"
|
||||||
|
if err := getJSON(ctx, t.hc, t.log, rawURL, nil, &eps); err != nil {
|
||||||
|
return nil, fmt.Errorf("tvmaze episodes %s: %w", id, err)
|
||||||
|
}
|
||||||
|
out := map[int]int{}
|
||||||
|
for _, e := range eps {
|
||||||
|
out[e.Season]++
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package metadata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTVMaze(t *testing.T, url string) *TVMaze {
|
||||||
|
t.Helper()
|
||||||
|
c, err := NewTVMaze(TVMazeConfig{BaseURL: url}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewTVMaze: %v", err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTVMaze_SearchSeries(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/search/shows" || r.URL.Query().Get("q") != "Fargo" {
|
||||||
|
t.Errorf("request = %s?%s", r.URL.Path, r.URL.RawQuery)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"score":0.9,"show":{"id":1,"name":"Fargo","premiered":"2014-04-15",
|
||||||
|
"externals":{"thetvdb":269613,"imdb":"tt2802850"}}},
|
||||||
|
{"score":0.1,"show":{"id":2,"name":"Other","premiered":"2010-01-01",
|
||||||
|
"externals":{"thetvdb":0,"imdb":"tt999"}}}
|
||||||
|
]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
got, err := newTVMaze(t, srv.URL).Search(context.Background(), Query{Type: Series, Title: "Fargo", Year: 2014})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("got %d candidates", len(got))
|
||||||
|
}
|
||||||
|
c := got[0]
|
||||||
|
if c.Provider != "tvmaze" || c.ID != "1" || c.Title != "Fargo" || c.Year != 2014 {
|
||||||
|
t.Errorf("candidate = %+v", c)
|
||||||
|
}
|
||||||
|
// TVDB-id из externals → тег папки.
|
||||||
|
if c.TagProvider != "tvdb" || c.TagID != "269613" {
|
||||||
|
t.Errorf("tag = %s/%s, want tvdb/269613", c.TagProvider, c.TagID)
|
||||||
|
}
|
||||||
|
// Без thetvdb → фолбэк на imdb.
|
||||||
|
if got[1].TagProvider != "imdb" || got[1].TagID != "tt999" {
|
||||||
|
t.Errorf("fallback tag = %s/%s", got[1].TagProvider, got[1].TagID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTVMaze_SearchMovieEmpty(t *testing.T) {
|
||||||
|
// Для фильмов TVMaze не вызывается вовсе.
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
|
t.Error("movie search must not hit the network")
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
got, err := newTVMaze(t, srv.URL).Search(context.Background(), Query{Type: Movie, Title: "X"})
|
||||||
|
if err != nil || got != nil {
|
||||||
|
t.Errorf("movie search = %v, %v; want nil, nil", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTVMaze_SeasonEpisodeCounts(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/shows/1/episodes" {
|
||||||
|
t.Errorf("path = %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"season":1,"number":1},{"season":1,"number":2},
|
||||||
|
{"season":2,"number":1}
|
||||||
|
]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
counts, err := newTVMaze(t, srv.URL).SeasonEpisodeCounts(context.Background(), "1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SeasonEpisodeCounts: %v", err)
|
||||||
|
}
|
||||||
|
if counts[1] != 2 || counts[2] != 1 {
|
||||||
|
t.Errorf("counts = %v", counts)
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-6
@@ -12,6 +12,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
@@ -36,6 +37,7 @@ type Client struct {
|
|||||||
hc *http.Client
|
hc *http.Client
|
||||||
user string
|
user string
|
||||||
pass string
|
pass string
|
||||||
|
log *slog.Logger
|
||||||
mu sync.Mutex // сериализует логин
|
mu sync.Mutex // сериализует логин
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +49,7 @@ type Torrent struct {
|
|||||||
SavePath string `json:"save_path"`
|
SavePath string `json:"save_path"`
|
||||||
ContentPath string `json:"content_path"`
|
ContentPath string `json:"content_path"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
|
Tags string `json:"tags"` // через запятую
|
||||||
Progress float64 `json:"progress"`
|
Progress float64 `json:"progress"`
|
||||||
AmountLeft int64 `json:"amount_left"`
|
AmountLeft int64 `json:"amount_left"`
|
||||||
AddedOn int64 `json:"added_on"`
|
AddedOn int64 `json:"added_on"`
|
||||||
@@ -54,8 +57,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"`
|
||||||
@@ -70,8 +76,8 @@ type AddRequest struct {
|
|||||||
Paused bool
|
Paused bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// New создаёт клиент с собственным cookie-jar.
|
// New создаёт клиент с собственным cookie-jar. logger nil → slog.Default().
|
||||||
func New(cfg Config) (*Client, error) {
|
func New(cfg Config, logger *slog.Logger) (*Client, error) {
|
||||||
base, err := url.Parse(strings.TrimRight(cfg.URL, "/"))
|
base, err := url.Parse(strings.TrimRight(cfg.URL, "/"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse qbittorrent url %q: %w", cfg.URL, err)
|
return nil, fmt.Errorf("parse qbittorrent url %q: %w", cfg.URL, err)
|
||||||
@@ -84,11 +90,15 @@ func New(cfg Config) (*Client, error) {
|
|||||||
if timeout == 0 {
|
if timeout == 0 {
|
||||||
timeout = 30 * time.Second
|
timeout = 30 * time.Second
|
||||||
}
|
}
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
return &Client{
|
return &Client{
|
||||||
base: base,
|
base: base,
|
||||||
hc: &http.Client{Jar: jar, Timeout: timeout},
|
hc: &http.Client{Jar: jar, Timeout: timeout},
|
||||||
user: cfg.Username,
|
user: cfg.Username,
|
||||||
pass: cfg.Password,
|
pass: cfg.Password,
|
||||||
|
log: logger,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,9 +125,12 @@ func (c *Client) login(ctx context.Context) error {
|
|||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
||||||
if resp.StatusCode != http.StatusOK || strings.TrimSpace(string(body)) != "Ok." {
|
if resp.StatusCode != http.StatusOK || strings.TrimSpace(string(body)) != "Ok." {
|
||||||
|
c.log.Error("qbittorrent: login failed",
|
||||||
|
"status", resp.StatusCode, "body", strings.TrimSpace(string(body)))
|
||||||
return fmt.Errorf("qbittorrent login failed: status %d body %q",
|
return fmt.Errorf("qbittorrent login failed: status %d body %q",
|
||||||
resp.StatusCode, strings.TrimSpace(string(body)))
|
resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
}
|
}
|
||||||
|
c.log.Debug("qbittorrent: login ok", "user", c.user)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +147,7 @@ func (c *Client) do(ctx context.Context, build func() (*http.Request, error)) (*
|
|||||||
}
|
}
|
||||||
if resp.StatusCode == http.StatusForbidden {
|
if resp.StatusCode == http.StatusForbidden {
|
||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
|
c.log.Debug("qbittorrent: session expired (403), re-login")
|
||||||
if err := c.login(ctx); err != nil {
|
if err := c.login(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -195,8 +209,13 @@ func (c *Client) Add(ctx context.Context, ar AddRequest) error {
|
|||||||
resp.StatusCode, strings.TrimSpace(string(body)))
|
resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(string(body)) == "Fails." {
|
if strings.TrimSpace(string(body)) == "Fails." {
|
||||||
|
c.log.Error("qbittorrent: add rejected",
|
||||||
|
"category", ar.Category, "urls", len(ar.URLs), "torrents", len(ar.Torrents))
|
||||||
return fmt.Errorf("qbittorrent add: rejected (Fails.)")
|
return fmt.Errorf("qbittorrent add: rejected (Fails.)")
|
||||||
}
|
}
|
||||||
|
c.log.Info("qbittorrent: torrent added",
|
||||||
|
"category", ar.Category, "save_path", ar.SavePath,
|
||||||
|
"urls", len(ar.URLs), "torrents", len(ar.Torrents), "paused", ar.Paused)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,11 +241,13 @@ func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, erro
|
|||||||
if err := json.NewDecoder(resp.Body).Decode(&ts); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&ts); err != nil {
|
||||||
return nil, fmt.Errorf("decode qbittorrent info: %w", err)
|
return nil, fmt.Errorf("decode qbittorrent info: %w", err)
|
||||||
}
|
}
|
||||||
|
c.log.Debug("qbittorrent: torrents fetched", "category", category, "count", len(ts))
|
||||||
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))
|
||||||
@@ -245,5 +266,6 @@ func (c *Client) Files(ctx context.Context, hash string) ([]File, error) {
|
|||||||
if err := json.NewDecoder(resp.Body).Decode(&fs); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&fs); err != nil {
|
||||||
return nil, fmt.Errorf("decode qbittorrent files: %w", err)
|
return nil, fmt.Errorf("decode qbittorrent files: %w", err)
|
||||||
}
|
}
|
||||||
|
c.log.Debug("qbittorrent: files fetched", "hash", hash, "count", len(fs))
|
||||||
return fs, nil
|
return fs, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ func fakeQBittorrent(t *testing.T, info string) *httptest.Server {
|
|||||||
|
|
||||||
func newClient(t *testing.T, url string) *Client {
|
func newClient(t *testing.T, url string) *Client {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
c, err := New(Config{URL: url, Username: "admin", Password: "secret"})
|
c, err := New(Config{URL: url, Username: "admin", Password: "secret"}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New: %v", err)
|
t.Fatalf("New: %v", err)
|
||||||
}
|
}
|
||||||
@@ -107,7 +107,7 @@ func TestTorrents(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoginFailure(t *testing.T) {
|
func TestLoginFailure(t *testing.T) {
|
||||||
srv := fakeQBittorrent(t, "[]")
|
srv := fakeQBittorrent(t, "[]")
|
||||||
c, err := New(Config{URL: srv.URL, Username: "admin", Password: "wrong"})
|
c, err := New(Config{URL: srv.URL, Username: "admin", Password: "wrong"}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func TestIntegration_RecognizeSeries(t *testing.T) {
|
|||||||
provider, err := llm.New(llm.Config{
|
provider, err := llm.New(llm.Config{
|
||||||
Type: "openai-compat", BaseURL: base, APIKey: key, Model: model,
|
Type: "openai-compat", BaseURL: base, APIKey: key, Model: model,
|
||||||
Timeout: 90 * time.Second,
|
Timeout: 90 * time.Second,
|
||||||
})
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("llm.New: %v", err)
|
t.Fatalf("llm.New: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,17 @@ import (
|
|||||||
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||||
)
|
)
|
||||||
|
|
||||||
// matchMetadata сверяет план с включёнными базами: ищет по названию+году и,
|
// maxCandidates — потолок на число сохраняемых кандидатов для ручного выбора.
|
||||||
// если ровно один кандидат уверенно совпадает (название и год), возвращает
|
const maxCandidates = 8
|
||||||
// матч с официальным id и каноническим именем. Несколько кандидатов или их
|
|
||||||
// отсутствие → nil (тогда авто-раскладки не будет). Ошибки провайдера не
|
// matchMetadata сверяет план с включёнными базами. Возвращает (а) единичный
|
||||||
// валят распознавание — просто нет матча.
|
// сильный матч — ровно один кандидат с совпадением названия и года (для него
|
||||||
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) *Match {
|
// тянем число серий и используем для авто), либо nil; (б) список кандидатов
|
||||||
|
// из всех провайдеров (топ-N, дедуп) — чтобы человек мог выбрать в review,
|
||||||
|
// когда сильного матча нет. Ошибки провайдера не валят распознавание.
|
||||||
|
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []metadata.Candidate) {
|
||||||
if len(r.providers) == 0 {
|
if len(r.providers) == 0 {
|
||||||
return nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
mt := metadata.Movie
|
mt := metadata.Movie
|
||||||
if plan.Type == MediaSeries {
|
if plan.Type == MediaSeries {
|
||||||
@@ -28,29 +31,69 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) *Match {
|
|||||||
}
|
}
|
||||||
matchTitles := normSet(plan.Title, plan.OriginalTitle)
|
matchTitles := normSet(plan.Title, plan.OriginalTitle)
|
||||||
|
|
||||||
|
var match *Match
|
||||||
|
var candidates []metadata.Candidate
|
||||||
|
seen := map[string]bool{}
|
||||||
|
|
||||||
for _, p := range r.providers {
|
for _, p := range r.providers {
|
||||||
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: searchTitle, Year: plan.Year})
|
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: searchTitle, Year: plan.Year})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.log.Warn("recognize: metadata search failed", "provider", p.Name(), "err", err)
|
r.log.Warn("recognize: metadata search failed", "provider", p.Name(), "err", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Копим кандидатов для выбора (дедуп по провайдеру+id, потолок).
|
||||||
|
for _, c := range cands {
|
||||||
|
key := c.Provider + ":" + c.ID
|
||||||
|
if seen[key] || len(candidates) >= maxCandidates {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
candidates = append(candidates, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Единичный сильный матч ищем у первого подходящего провайдера.
|
||||||
|
if match != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
strong := strongMatches(cands, plan.Year, matchTitles)
|
strong := strongMatches(cands, plan.Year, matchTitles)
|
||||||
if len(strong) != 1 {
|
if len(strong) != 1 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
c := strong[0]
|
match = r.buildMatch(ctx, p, strong[0], mt)
|
||||||
match := &Match{Provider: c.Provider, ProviderID: c.ID, Title: c.Title, Year: c.Year}
|
|
||||||
if mt == metadata.Series {
|
|
||||||
if counts, cerr := p.SeasonEpisodeCounts(ctx, c.ID); cerr == nil {
|
|
||||||
match.SeasonEpisodeCounts = counts
|
|
||||||
} else {
|
|
||||||
r.log.Warn("recognize: episode counts failed",
|
|
||||||
"provider", p.Name(), "id", c.ID, "err", cerr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return match
|
|
||||||
}
|
}
|
||||||
return nil
|
return match, candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMatch тянет число серий (по нативному id) и собирает Match с
|
||||||
|
// тег-предпочтительным провенансом.
|
||||||
|
func (r *Recognizer) buildMatch(ctx context.Context, p metadata.Provider, c metadata.Candidate, mt metadata.MediaType) *Match {
|
||||||
|
var counts map[int]int
|
||||||
|
if mt == metadata.Series {
|
||||||
|
if got, err := p.SeasonEpisodeCounts(ctx, c.ID); err == nil {
|
||||||
|
counts = got
|
||||||
|
} else {
|
||||||
|
r.log.Warn("recognize: episode counts failed", "provider", p.Name(), "id", c.ID, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prov, pid := CandidateTag(c)
|
||||||
|
return &Match{
|
||||||
|
Provider: prov,
|
||||||
|
ProviderID: pid,
|
||||||
|
Title: c.Title,
|
||||||
|
Year: c.Year,
|
||||||
|
SeasonEpisodeCounts: counts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CandidateTag — провайдер и id для тега папки Jellyfin: внешний (из
|
||||||
|
// TagProvider/TagID, напр. TVMaze → tvdb/imdb), если есть, иначе сам провайдер
|
||||||
|
// поиска. Используется и в матче, и при сохранении кандидатов.
|
||||||
|
func CandidateTag(c metadata.Candidate) (provider, id string) {
|
||||||
|
if c.TagProvider != "" {
|
||||||
|
return c.TagProvider, c.TagID
|
||||||
|
}
|
||||||
|
return c.Provider, c.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// strongMatches оставляет кандидатов, чьё название совпадает с одним из
|
// strongMatches оставляет кандидатов, чьё название совпадает с одним из
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func TestMatchMetadata_SingleStrong(t *testing.T) {
|
|||||||
{Provider: "tmdb", ID: "604", Title: "The Matrix Reloaded", Year: 2003},
|
{Provider: "tmdb", ID: "604", Title: "The Matrix Reloaded", Year: 2003},
|
||||||
}}
|
}}
|
||||||
r := recognizerWith(p)
|
r := recognizerWith(p)
|
||||||
m := r.matchMetadata(context.Background(),
|
m, _ := r.matchMetadata(context.Background(),
|
||||||
Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999})
|
Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999})
|
||||||
if m == nil {
|
if m == nil {
|
||||||
t.Fatal("expected match")
|
t.Fatal("expected match")
|
||||||
@@ -61,16 +61,60 @@ func TestMatchMetadata_AmbiguousNoMatch(t *testing.T) {
|
|||||||
{ID: "2", Title: "Fargo", Year: 2014},
|
{ID: "2", Title: "Fargo", Year: 2014},
|
||||||
}}
|
}}
|
||||||
r := recognizerWith(p)
|
r := recognizerWith(p)
|
||||||
if m := r.matchMetadata(context.Background(),
|
if m, _ := r.matchMetadata(context.Background(),
|
||||||
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014}); m != nil {
|
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014}); m != nil {
|
||||||
t.Errorf("ambiguous must not match, got %+v", m)
|
t.Errorf("ambiguous must not match, got %+v", m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMatchMetadata_ReturnsCandidates(t *testing.T) {
|
||||||
|
// Нет сильного матча (разные названия), но кандидаты собраны для выбора.
|
||||||
|
p := &fakeProvider{name: "tvmaze", candidates: []metadata.Candidate{
|
||||||
|
{Provider: "tvmaze", ID: "1", Title: "Fargo", Year: 2014, TagProvider: "tvdb", TagID: "269613"},
|
||||||
|
{Provider: "tvmaze", ID: "2", Title: "Fargo Idaho", Year: 2010},
|
||||||
|
{Provider: "tvmaze", ID: "1", Title: "Fargo", Year: 2014}, // дубль по id
|
||||||
|
}}
|
||||||
|
r := recognizerWith(p)
|
||||||
|
m, cands := r.matchMetadata(context.Background(),
|
||||||
|
Plan{Type: MediaSeries, Title: "Совсем другое", Year: 2014})
|
||||||
|
if m != nil {
|
||||||
|
t.Errorf("strong match не ожидался: %+v", m)
|
||||||
|
}
|
||||||
|
if len(cands) != 2 { // дубль отброшен
|
||||||
|
t.Fatalf("candidates = %d, want 2: %+v", len(cands), cands)
|
||||||
|
}
|
||||||
|
// CandidateTag даёт внешний TVDB-id для первого.
|
||||||
|
prov, id := CandidateTag(cands[0])
|
||||||
|
if prov != "tvdb" || id != "269613" {
|
||||||
|
t.Errorf("tag = %s/%s", prov, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecognize_PopulatesCandidates(t *testing.T) {
|
||||||
|
in := Input{Name: "Show.S01", Files: []File{{Path: "e1.mkv", Size: 1}}}
|
||||||
|
resp := `{"type":"series","title":"Show","year":2020,"confidence":0.9,
|
||||||
|
"files":[{"src":"e1.mkv","role":"episode","season":1,"episode":1}]}`
|
||||||
|
p := &fakeProvider{name: "tvmaze", candidates: []metadata.Candidate{
|
||||||
|
{Provider: "tvmaze", ID: "1", Title: "Show One", Year: 2020},
|
||||||
|
{Provider: "tvmaze", ID: "2", Title: "Show Two", Year: 2019},
|
||||||
|
}}
|
||||||
|
r := New(&fakeLLM{responses: []string{resp}}, []metadata.Provider{p}, Config{}, testLogger())
|
||||||
|
res, err := r.Recognize(context.Background(), in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recognize: %v", err)
|
||||||
|
}
|
||||||
|
if res.Match != nil {
|
||||||
|
t.Errorf("strong match не ожидался")
|
||||||
|
}
|
||||||
|
if len(res.Candidates) != 2 {
|
||||||
|
t.Errorf("Result.Candidates = %d, want 2", len(res.Candidates))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMatchMetadata_YearMismatch(t *testing.T) {
|
func TestMatchMetadata_YearMismatch(t *testing.T) {
|
||||||
p := &fakeProvider{candidates: []metadata.Candidate{{ID: "1", Title: "X", Year: 1990}}}
|
p := &fakeProvider{candidates: []metadata.Candidate{{ID: "1", Title: "X", Year: 1990}}}
|
||||||
r := recognizerWith(p)
|
r := recognizerWith(p)
|
||||||
if m := r.matchMetadata(context.Background(),
|
if m, _ := r.matchMetadata(context.Background(),
|
||||||
Plan{Type: MediaMovie, Title: "X", Year: 2020}); m != nil {
|
Plan{Type: MediaMovie, Title: "X", Year: 2020}); m != nil {
|
||||||
t.Errorf("year mismatch must not match, got %+v", m)
|
t.Errorf("year mismatch must not match, got %+v", m)
|
||||||
}
|
}
|
||||||
@@ -81,20 +125,44 @@ func TestMatchMetadata_OriginalTitle(t *testing.T) {
|
|||||||
{ID: "1", Title: "Leon", OriginalTitle: "Léon", Year: 1994},
|
{ID: "1", Title: "Leon", OriginalTitle: "Léon", Year: 1994},
|
||||||
}}
|
}}
|
||||||
r := recognizerWith(p)
|
r := recognizerWith(p)
|
||||||
m := r.matchMetadata(context.Background(),
|
m, _ := r.matchMetadata(context.Background(),
|
||||||
Plan{Type: MediaMovie, Title: "Léon", Year: 1994})
|
Plan{Type: MediaMovie, Title: "Léon", Year: 1994})
|
||||||
if m == nil || m.ProviderID != "1" {
|
if m == nil || m.ProviderID != "1" {
|
||||||
t.Errorf("should match by original title, got %+v", m)
|
t.Errorf("should match by original title, got %+v", m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMatchMetadata_TagFromExternal(t *testing.T) {
|
||||||
|
// TVMaze-стиль: нативный id для счёта серий, внешний TVDB-id для тега.
|
||||||
|
p := &fakeProvider{
|
||||||
|
name: "tvmaze",
|
||||||
|
candidates: []metadata.Candidate{
|
||||||
|
{Provider: "tvmaze", ID: "1", Title: "Fargo", Year: 2014, TagProvider: "tvdb", TagID: "269613"},
|
||||||
|
},
|
||||||
|
counts: map[int]int{1: 10},
|
||||||
|
}
|
||||||
|
r := recognizerWith(p)
|
||||||
|
m, _ := r.matchMetadata(context.Background(),
|
||||||
|
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014})
|
||||||
|
if m == nil {
|
||||||
|
t.Fatal("expected match")
|
||||||
|
}
|
||||||
|
// Провенанс/тег — внешний TVDB-id, а не нативный tvmaze.
|
||||||
|
if m.Provider != "tvdb" || m.ProviderID != "269613" {
|
||||||
|
t.Errorf("match provider = %s/%s, want tvdb/269613", m.Provider, m.ProviderID)
|
||||||
|
}
|
||||||
|
if m.SeasonEpisodeCounts[1] != 10 {
|
||||||
|
t.Errorf("counts not fetched by native id: %+v", m.SeasonEpisodeCounts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMatchMetadata_SeriesFetchesCounts(t *testing.T) {
|
func TestMatchMetadata_SeriesFetchesCounts(t *testing.T) {
|
||||||
p := &fakeProvider{
|
p := &fakeProvider{
|
||||||
candidates: []metadata.Candidate{{ID: "60622", Title: "Fargo", Year: 2014}},
|
candidates: []metadata.Candidate{{ID: "60622", Title: "Fargo", Year: 2014}},
|
||||||
counts: map[int]int{1: 10, 2: 10},
|
counts: map[int]int{1: 10, 2: 10},
|
||||||
}
|
}
|
||||||
r := recognizerWith(p)
|
r := recognizerWith(p)
|
||||||
m := r.matchMetadata(context.Background(),
|
m, _ := r.matchMetadata(context.Background(),
|
||||||
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014})
|
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014})
|
||||||
if m == nil || m.SeasonEpisodeCounts[1] != 10 {
|
if m == nil || m.SeasonEpisodeCounts[1] != 10 {
|
||||||
t.Errorf("counts not fetched: %+v", m)
|
t.Errorf("counts not fetched: %+v", m)
|
||||||
@@ -104,7 +172,7 @@ func TestMatchMetadata_SeriesFetchesCounts(t *testing.T) {
|
|||||||
func TestMatchMetadata_ProviderErrorNoMatch(t *testing.T) {
|
func TestMatchMetadata_ProviderErrorNoMatch(t *testing.T) {
|
||||||
p := &fakeProvider{searchErr: errors.New("upstream down")}
|
p := &fakeProvider{searchErr: errors.New("upstream down")}
|
||||||
r := recognizerWith(p)
|
r := recognizerWith(p)
|
||||||
if m := r.matchMetadata(context.Background(),
|
if m, _ := r.matchMetadata(context.Background(),
|
||||||
Plan{Type: MediaMovie, Title: "X", Year: 2000}); m != nil {
|
Plan{Type: MediaMovie, Title: "X", Year: 2000}); m != nil {
|
||||||
t.Errorf("provider error must yield no match, got %+v", m)
|
t.Errorf("provider error must yield no match, got %+v", m)
|
||||||
}
|
}
|
||||||
@@ -112,7 +180,7 @@ func TestMatchMetadata_ProviderErrorNoMatch(t *testing.T) {
|
|||||||
|
|
||||||
func TestMatchMetadata_Disabled(t *testing.T) {
|
func TestMatchMetadata_Disabled(t *testing.T) {
|
||||||
r := recognizerWith(nil)
|
r := recognizerWith(nil)
|
||||||
if m := r.matchMetadata(context.Background(), Plan{Type: MediaMovie, Title: "X"}); m != nil {
|
if m, _ := r.matchMetadata(context.Background(), Plan{Type: MediaMovie, Title: "X"}); m != nil {
|
||||||
t.Errorf("no providers → no match, got %+v", m)
|
t.Errorf("no providers → no match, got %+v", m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -115,12 +115,13 @@ type Match struct {
|
|||||||
|
|
||||||
// Result — итог распознавания.
|
// Result — итог распознавания.
|
||||||
type Result struct {
|
type Result struct {
|
||||||
Plan Plan
|
Plan Plan
|
||||||
PreParse PreParse
|
PreParse PreParse
|
||||||
Decision Decision
|
Decision Decision
|
||||||
Match *Match // подтверждённый матч в базе (nil — нет)
|
Match *Match // подтверждённый единичный матч (nil — нет)
|
||||||
Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи разбора)
|
Candidates []metadata.Candidate // кандидаты базы для ручного выбора в review
|
||||||
Raw string // сырой ответ LLM последней попытки (для recognition.raw_llm)
|
Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи)
|
||||||
|
Raw string // сырой ответ LLM последней попытки
|
||||||
}
|
}
|
||||||
|
|
||||||
// LLM — нужная recognize часть провайдера.
|
// LLM — нужная recognize часть провайдера.
|
||||||
@@ -234,8 +235,9 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Сверка с базой: подтверждаем id + каноническое имя; при матче имя/год
|
// Сверка с базой: подтверждаем id + каноническое имя; при матче имя/год
|
||||||
// в плане заменяем на каноничные.
|
// в плане заменяем на каноничные. Кандидаты копим для ручного выбора в
|
||||||
match := r.matchMetadata(ctx, plan)
|
// review, когда единичного сильного матча нет.
|
||||||
|
match, candidates := r.matchMetadata(ctx, plan)
|
||||||
if match != nil {
|
if match != nil {
|
||||||
plan.Title = match.Title
|
plan.Title = match.Title
|
||||||
if match.Year != 0 {
|
if match.Year != 0 {
|
||||||
@@ -247,13 +249,15 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
|||||||
r.log.Info("recognize: done",
|
r.log.Info("recognize: done",
|
||||||
"type", plan.Type, "title", plan.Title, "year", plan.Year,
|
"type", plan.Type, "title", plan.Title, "year", plan.Year,
|
||||||
"files", len(plan.Files), "attempts", attempts,
|
"files", len(plan.Files), "attempts", attempts,
|
||||||
"matched", match != nil, "auto", dec.Auto, "reasons", len(dec.Reasons))
|
"matched", match != nil, "candidates", len(candidates),
|
||||||
|
"auto", dec.Auto, "reasons", len(dec.Reasons))
|
||||||
return Result{
|
return Result{
|
||||||
Plan: plan,
|
Plan: plan,
|
||||||
PreParse: pre,
|
PreParse: pre,
|
||||||
Decision: dec,
|
Decision: dec,
|
||||||
Match: match,
|
Match: match,
|
||||||
Attempts: attempts,
|
Candidates: candidates,
|
||||||
Raw: raw,
|
Attempts: attempts,
|
||||||
|
Raw: raw,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
func parsePlan(raw string, in Input) (Plan, error) {
|
func parsePlan(raw string, in Input) (Plan, error) {
|
||||||
jsonStr, err := llm.ExtractJSONObject(raw)
|
jsonStr, err := llm.ExtractJSONObject(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Plan{}, fmt.Errorf("в ответе нет JSON-объекта")
|
return Plan{}, fmt.Errorf("no JSON object in response")
|
||||||
}
|
}
|
||||||
|
|
||||||
var p Plan
|
var p Plan
|
||||||
@@ -25,7 +25,7 @@ func parsePlan(raw string, in Input) (Plan, error) {
|
|||||||
// Повторяем без строгого режима: лишние поля — не повод падать,
|
// Повторяем без строгого режима: лишние поля — не повод падать,
|
||||||
// но если и так не разобралось — это ошибка схемы.
|
// но если и так не разобралось — это ошибка схемы.
|
||||||
if err2 := json.Unmarshal([]byte(jsonStr), &p); err2 != nil {
|
if err2 := json.Unmarshal([]byte(jsonStr), &p); err2 != nil {
|
||||||
return Plan{}, fmt.Errorf("JSON не разобран: %v", err2)
|
return Plan{}, fmt.Errorf("JSON not parsed: %v", err2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,15 +42,15 @@ func validateSchema(p *Plan, in Input) error {
|
|||||||
switch p.Type {
|
switch p.Type {
|
||||||
case MediaMovie, MediaSeries:
|
case MediaMovie, MediaSeries:
|
||||||
case "":
|
case "":
|
||||||
return fmt.Errorf("поле type пустое (ожидалось movie или series)")
|
return fmt.Errorf("field type is empty (expected movie or series)")
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("неизвестный type %q", p.Type)
|
return fmt.Errorf("unknown type %q", p.Type)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(p.Title) == "" {
|
if strings.TrimSpace(p.Title) == "" {
|
||||||
return fmt.Errorf("поле title пустое")
|
return fmt.Errorf("field title is empty")
|
||||||
}
|
}
|
||||||
if len(p.Files) == 0 {
|
if len(p.Files) == 0 {
|
||||||
return fmt.Errorf("список files пуст")
|
return fmt.Errorf("files list is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
known := make(map[string]bool, len(in.Files))
|
known := make(map[string]bool, len(in.Files))
|
||||||
@@ -61,16 +61,16 @@ func validateSchema(p *Plan, in Input) error {
|
|||||||
for i := range p.Files {
|
for i := range p.Files {
|
||||||
pf := &p.Files[i]
|
pf := &p.Files[i]
|
||||||
if !pf.Role.valid() {
|
if !pf.Role.valid() {
|
||||||
return fmt.Errorf("файл %q: неизвестная role %q", pf.Src, pf.Role)
|
return fmt.Errorf("file %q: unknown role %q", pf.Src, pf.Role)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(pf.Src) == "" {
|
if strings.TrimSpace(pf.Src) == "" {
|
||||||
return fmt.Errorf("файл с пустым src")
|
return fmt.Errorf("file with empty src")
|
||||||
}
|
}
|
||||||
if !known[pf.Src] {
|
if !known[pf.Src] {
|
||||||
return fmt.Errorf("src %q не найден среди файлов торрента", pf.Src)
|
return fmt.Errorf("src %q not found among torrent files", pf.Src)
|
||||||
}
|
}
|
||||||
if pf.Role == RoleEpisode && pf.Episode == nil {
|
if pf.Role == RoleEpisode && pf.Episode == nil {
|
||||||
return fmt.Errorf("серия %q без номера episode", pf.Src)
|
return fmt.Errorf("episode %q has no episode number", pf.Src)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -37,14 +37,14 @@ func TestValidateSchema_Errors(t *testing.T) {
|
|||||||
p Plan
|
p Plan
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"empty type", Plan{Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: RoleMain}}}, "type пустое"},
|
{"empty type", Plan{Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: RoleMain}}}, "type is empty"},
|
||||||
{"bad type", Plan{Type: "show", Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: RoleMain}}}, "неизвестный type"},
|
{"bad type", Plan{Type: "show", Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: RoleMain}}}, "unknown type"},
|
||||||
{"empty title", Plan{Type: MediaMovie, Files: []PlanFile{{Src: "a.mkv", Role: RoleMain}}}, "title пустое"},
|
{"empty title", Plan{Type: MediaMovie, Files: []PlanFile{{Src: "a.mkv", Role: RoleMain}}}, "title is empty"},
|
||||||
{"no files", Plan{Type: MediaMovie, Title: "x"}, "files пуст"},
|
{"no files", Plan{Type: MediaMovie, Title: "x"}, "files list is empty"},
|
||||||
{"bad role", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: "boss"}}}, "неизвестная role"},
|
{"bad role", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: "boss"}}}, "unknown role"},
|
||||||
{"empty src", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "", Role: RoleMain}}}, "пустым src"},
|
{"empty src", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "", Role: RoleMain}}}, "empty src"},
|
||||||
{"unknown src", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "z.mkv", Role: RoleMain}}}, "не найден"},
|
{"unknown src", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "z.mkv", Role: RoleMain}}}, "not found among torrent files"},
|
||||||
{"episode no num", Plan{Type: MediaSeries, Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: RoleEpisode, Season: intp(1)}}}, "без номера episode"},
|
{"episode no num", Plan{Type: MediaSeries, Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: RoleEpisode, Season: intp(1)}}}, "has no episode number"},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -159,6 +159,18 @@ func (s *Store) FindActiveByInfohash(ctx context.Context, infohash string) (*Dow
|
|||||||
return &d, nil
|
return &d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExistsByInfohash сообщает, есть ли хоть одна загрузка (в любом состоянии)
|
||||||
|
// с данным infohash. Discovery усыновляет раздачу только если её ещё не
|
||||||
|
// видели — так готовые задачи не переобрабатываются на каждом тике.
|
||||||
|
func (s *Store) ExistsByInfohash(ctx context.Context, infohash string) (bool, error) {
|
||||||
|
var n int
|
||||||
|
if err := s.DB.GetContext(ctx, &n,
|
||||||
|
`SELECT COUNT(1) FROM download WHERE infohash = ?`, infohash); err != nil {
|
||||||
|
return false, fmt.Errorf("exists by infohash: %w", err)
|
||||||
|
}
|
||||||
|
return n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SetDownloadState переводит загрузку в новое состояние. Ключ
|
// SetDownloadState переводит загрузку в новое состояние. Ключ
|
||||||
// идемпотентности пересчитывается из текущего infohash: для терминального
|
// идемпотентности пересчитывается из текущего infohash: для терминального
|
||||||
// состояния снимается (NULL), иначе равен infohash — так partial unique
|
// состояния снимается (NULL), иначе равен infohash — так partial unique
|
||||||
|
|||||||
@@ -228,3 +228,92 @@ func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) erro
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Кандидаты базы метаданных (metadata_candidate) ---
|
||||||
|
|
||||||
|
// MetadataCandidate — строка таблицы metadata_candidate. provider/provider_id
|
||||||
|
// хранят значения для тега Jellyfin (напр. TVMaze отдаёт внешний TVDB-id —
|
||||||
|
// см. recognize), а не обязательно нативный id провайдера поиска.
|
||||||
|
type MetadataCandidate struct {
|
||||||
|
ID int64 `db:"id"`
|
||||||
|
RecognitionID int64 `db:"recognition_id"`
|
||||||
|
Provider string `db:"provider"`
|
||||||
|
ProviderID string `db:"provider_id"`
|
||||||
|
Title sql.NullString `db:"title"`
|
||||||
|
Year sql.NullInt64 `db:"year"`
|
||||||
|
Chosen bool `db:"chosen"`
|
||||||
|
CreatedAt string `db:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCandidates вставляет кандидатов распознавания одной транзакцией.
|
||||||
|
func (s *Store) CreateCandidates(ctx context.Context, cands []MetadataCandidate) error {
|
||||||
|
if len(cands) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
const q = `
|
||||||
|
INSERT INTO metadata_candidate (recognition_id, provider, provider_id, title, year)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`
|
||||||
|
for _, c := range cands {
|
||||||
|
if _, err := tx.ExecContext(ctx, q,
|
||||||
|
c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year); err != nil {
|
||||||
|
return fmt.Errorf("insert candidate: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit candidates: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCandidatesByRecognition возвращает кандидатов попытки распознавания.
|
||||||
|
func (s *Store) ListCandidatesByRecognition(ctx context.Context, recognitionID int64) ([]MetadataCandidate, error) {
|
||||||
|
var out []MetadataCandidate
|
||||||
|
if err := s.DB.SelectContext(ctx, &out,
|
||||||
|
`SELECT * FROM metadata_candidate WHERE recognition_id = ? ORDER BY id`, recognitionID); err != nil {
|
||||||
|
return nil, fmt.Errorf("list candidates: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCandidate возвращает кандидата по id либо (nil, nil).
|
||||||
|
func (s *Store) GetCandidate(ctx context.Context, id int64) (*MetadataCandidate, error) {
|
||||||
|
var c MetadataCandidate
|
||||||
|
err := s.DB.GetContext(ctx, &c, `SELECT * FROM metadata_candidate WHERE id = ?`, id)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get candidate %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCandidateChosen помечает кандидата выбранным, снимая отметку с прочих в
|
||||||
|
// той же попытке распознавания.
|
||||||
|
func (s *Store) SetCandidateChosen(ctx context.Context, recognitionID, candidateID int64) error {
|
||||||
|
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE metadata_candidate SET chosen = 0 WHERE recognition_id = ?`, recognitionID); err != nil {
|
||||||
|
return fmt.Errorf("clear chosen: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE metadata_candidate SET chosen = 1 WHERE id = ? AND recognition_id = ?`,
|
||||||
|
candidateID, recognitionID); err != nil {
|
||||||
|
return fmt.Errorf("set chosen: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit chosen: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -159,6 +159,91 @@ func TestFileLinks_BatchLifecycle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCandidates_Lifecycle(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
dl := seedDownload(t, st)
|
||||||
|
recID, err := st.CreateRecognition(ctx, &Recognition{DownloadID: dl}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create recognition: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cands := []MetadataCandidate{
|
||||||
|
{RecognitionID: recID, Provider: "tvdb", ProviderID: "269613",
|
||||||
|
Title: NullString("Fargo"), Year: sql.NullInt64{Int64: 2014, Valid: true}},
|
||||||
|
{RecognitionID: recID, Provider: "tmdb", ProviderID: "60622",
|
||||||
|
Title: NullString("Fargo")},
|
||||||
|
}
|
||||||
|
if err := st.CreateCandidates(ctx, cands); err != nil {
|
||||||
|
t.Fatalf("create candidates: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := st.ListCandidatesByRecognition(ctx, recID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0].Provider != "tvdb" || got[0].ProviderID != "269613" {
|
||||||
|
t.Fatalf("candidates = %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
chosenID := got[0].ID
|
||||||
|
if err := st.SetCandidateChosen(ctx, recID, chosenID); err != nil {
|
||||||
|
t.Fatalf("set chosen: %v", err)
|
||||||
|
}
|
||||||
|
got, _ = st.ListCandidatesByRecognition(ctx, recID)
|
||||||
|
for _, c := range got {
|
||||||
|
want := c.ID == chosenID
|
||||||
|
if c.Chosen != want {
|
||||||
|
t.Errorf("candidate %d chosen = %v, want %v", c.ID, c.Chosen, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCandidate + переотметка.
|
||||||
|
single, err := st.GetCandidate(ctx, got[1].ID)
|
||||||
|
if err != nil || single == nil || single.Provider != "tmdb" {
|
||||||
|
t.Fatalf("get candidate = %+v, %v", single, err)
|
||||||
|
}
|
||||||
|
if err := st.SetCandidateChosen(ctx, recID, got[1].ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ = st.ListCandidatesByRecognition(ctx, recID)
|
||||||
|
if got[0].Chosen || !got[1].Chosen {
|
||||||
|
t.Errorf("re-choose failed: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExistsByInfohash(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
const ih = "aabbccddeeff00112233445566778899aabbccdd"
|
||||||
|
|
||||||
|
exists, err := st.ExistsByInfohash(ctx, ih)
|
||||||
|
if err != nil || exists {
|
||||||
|
t.Fatalf("пусто: exists=%v err=%v", exists, err)
|
||||||
|
}
|
||||||
|
if _, err := st.CreateDownload(ctx, newDownloading(ih)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
exists, err = st.ExistsByInfohash(ctx, ih)
|
||||||
|
if err != nil || !exists {
|
||||||
|
t.Fatalf("после вставки: exists=%v err=%v", exists, err)
|
||||||
|
}
|
||||||
|
// Терминальное состояние тоже считается «видели» (не реусыновляем).
|
||||||
|
id, _ := st.CreateDownload(ctx, newDownloading("ffffffffffffffffffffffffffffffffffffffff"))
|
||||||
|
_ = st.SetDownloadState(ctx, id, StateDone, "", "")
|
||||||
|
if ex, _ := st.ExistsByInfohash(ctx, "ffffffffffffffffffffffffffffffffffffffff"); !ex {
|
||||||
|
t.Error("done-задача должна считаться существующей")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCandidate_None(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
c, err := st.GetCandidate(context.Background(), 999)
|
||||||
|
if err != nil || c != nil {
|
||||||
|
t.Errorf("want nil,nil; got %+v, %v", c, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLatestBatchID_None(t *testing.T) {
|
func TestLatestBatchID_None(t *testing.T) {
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
dl := seedDownload(t, st)
|
dl := seedDownload(t, st)
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
package tgbot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// teleAPI — нужная боту часть клиента Telegram (его реализует
|
||||||
|
// *tgbotapi.BotAPI; в тестах подменяется фейком).
|
||||||
|
type teleAPI interface {
|
||||||
|
Send(c tgbotapi.Chattable) (tgbotapi.Message, error)
|
||||||
|
Request(c tgbotapi.Chattable) (*tgbotapi.APIResponse, error)
|
||||||
|
GetUpdatesChan(config tgbotapi.UpdateConfig) tgbotapi.UpdatesChannel
|
||||||
|
StopReceivingUpdates()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingestor принимает загрузку (ingest.Service).
|
||||||
|
type Ingestor interface {
|
||||||
|
Ingest(ctx context.Context, req ingest.Request) (ingest.Result, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reviewer — операции ревью (worker.Worker).
|
||||||
|
type Reviewer interface {
|
||||||
|
ReviewData(ctx context.Context, id int64) (*worker.ReviewData, error)
|
||||||
|
Apply(ctx context.Context, id int64) error
|
||||||
|
Refine(ctx context.Context, id int64, hint string) error
|
||||||
|
SetType(ctx context.Context, id int64, mediaType string) error
|
||||||
|
Defer(ctx context.Context, id int64) error
|
||||||
|
Cancel(ctx context.Context, id int64) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config — параметры бота.
|
||||||
|
type Config struct {
|
||||||
|
AllowedUserIDs []int64
|
||||||
|
WebBaseURL string // для deep-link «открыть в вебе» (опц.)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bot — Telegram-адаптер: приём загрузок и подтверждение раскладки.
|
||||||
|
type Bot struct {
|
||||||
|
api teleAPI
|
||||||
|
ingestor Ingestor
|
||||||
|
reviewer Reviewer
|
||||||
|
allowed map[int64]bool
|
||||||
|
webBase string
|
||||||
|
log *slog.Logger
|
||||||
|
|
||||||
|
mu sync.Mutex // защищает pending
|
||||||
|
pending map[int64]int64 // chatID → downloadID, ждущий подсказку
|
||||||
|
}
|
||||||
|
|
||||||
|
// New собирает бота поверх клиента Telegram.
|
||||||
|
func New(client teleAPI, ing Ingestor, rev Reviewer, cfg Config, log *slog.Logger) *Bot {
|
||||||
|
allowed := make(map[int64]bool, len(cfg.AllowedUserIDs))
|
||||||
|
for _, id := range cfg.AllowedUserIDs {
|
||||||
|
allowed[id] = true
|
||||||
|
}
|
||||||
|
return &Bot{
|
||||||
|
api: client,
|
||||||
|
ingestor: ing,
|
||||||
|
reviewer: rev,
|
||||||
|
allowed: allowed,
|
||||||
|
webBase: strings.TrimRight(cfg.WebBaseURL, "/"),
|
||||||
|
log: log,
|
||||||
|
pending: map[int64]int64{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pollTimeout = 30 // секунд long-poll
|
||||||
|
|
||||||
|
// Run крутит цикл обновлений до отмены ctx.
|
||||||
|
func (b *Bot) Run(ctx context.Context) {
|
||||||
|
b.log.Info("telegram bot started", "allowed_users", len(b.allowed))
|
||||||
|
cfg := tgbotapi.NewUpdate(0)
|
||||||
|
cfg.Timeout = pollTimeout
|
||||||
|
cfg.AllowedUpdates = []string{"message", "callback_query"}
|
||||||
|
|
||||||
|
updates := b.api.GetUpdatesChan(cfg)
|
||||||
|
defer b.api.StopReceivingUpdates()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
b.log.Info("telegram bot stopped")
|
||||||
|
return
|
||||||
|
case u, ok := <-updates:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.handleUpdate(ctx, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleUpdate(ctx context.Context, u tgbotapi.Update) {
|
||||||
|
switch {
|
||||||
|
case u.Message != nil:
|
||||||
|
b.handleMessage(ctx, u.Message)
|
||||||
|
case u.CallbackQuery != nil:
|
||||||
|
b.handleCallback(ctx, u.CallbackQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Входящие сообщения ---
|
||||||
|
|
||||||
|
func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
|
||||||
|
if m.From == nil || m.Chat == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !b.allowed[m.From.ID] {
|
||||||
|
b.log.Warn("telegram: denied user", "user_id", m.From.ID, "username", m.From.UserName)
|
||||||
|
b.send(m.Chat.ID, "Доступ запрещён.", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text := strings.TrimSpace(m.Text)
|
||||||
|
|
||||||
|
// Ждём подсказку для перераспознавания?
|
||||||
|
if id, ok := b.takePending(m.Chat.ID); ok && !strings.Contains(text, "magnet:") {
|
||||||
|
if err := b.reviewer.Refine(ctx, id, text); err != nil {
|
||||||
|
b.send(m.Chat.ID, "Не удалось: "+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+strconv.FormatInt(id, 10)+"…", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if text == "/start" || text == "/help" {
|
||||||
|
b.send(m.Chat.ID, helpText, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
source, context, ok := ParseMessage(text)
|
||||||
|
if !ok {
|
||||||
|
b.send(m.Chat.ID, "Не вижу magnet-ссылки. Перешлите сообщение торрент-бота или пришлите magnet.", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := b.ingestor.Ingest(ctx, ingest.Request{Source: source, Context: context})
|
||||||
|
if err != nil {
|
||||||
|
b.send(m.Chat.ID, "Ошибка приёма: "+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf("Принято #%d — %s.", res.DownloadID, res.State)
|
||||||
|
if res.Deduplicated {
|
||||||
|
msg = fmt.Sprintf("Уже в работе #%d — %s.", res.DownloadID, res.State)
|
||||||
|
}
|
||||||
|
b.send(m.Chat.ID, msg+"\nПозову, когда нужно подтверждение.", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
const helpText = `jellybit-бот: пришлите magnet-ссылку или перешлите сообщение торрент-бота — поставлю на закачку.
|
||||||
|
Когда раздача скачается и потребуется подтверждение раскладки, позову кнопками.`
|
||||||
|
|
||||||
|
// --- Колбэки (кнопки) ---
|
||||||
|
|
||||||
|
func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
||||||
|
if cq.From == nil || cq.Message == nil || cq.Message.Chat == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !b.allowed[cq.From.ID] {
|
||||||
|
b.answer(cq.ID, "Доступ запрещён")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
action, id, val := parseCallback(cq.Data)
|
||||||
|
if id == 0 {
|
||||||
|
b.answer(cq.ID, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chatID := cq.Message.Chat.ID
|
||||||
|
msgID := cq.Message.MessageID
|
||||||
|
|
||||||
|
var note string
|
||||||
|
var err error
|
||||||
|
switch action {
|
||||||
|
case "apply":
|
||||||
|
err = b.reviewer.Apply(ctx, id)
|
||||||
|
note = "Применяю…"
|
||||||
|
case "defer":
|
||||||
|
err = b.reviewer.Defer(ctx, id)
|
||||||
|
note = "Отложено"
|
||||||
|
case "reject":
|
||||||
|
err = b.reviewer.Cancel(ctx, id)
|
||||||
|
note = "Отклонено"
|
||||||
|
case "type":
|
||||||
|
err = b.reviewer.SetType(ctx, id, val)
|
||||||
|
note = "Меняю тип…"
|
||||||
|
case "refine":
|
||||||
|
b.setPending(chatID, id)
|
||||||
|
b.answer(cq.ID, "Жду подсказку")
|
||||||
|
b.send(chatID, "Ответьте сообщением с подсказкой для #"+strconv.FormatInt(id, 10)+".", nil)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
b.answer(cq.ID, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
b.answer(cq.ID, "Ошибка")
|
||||||
|
b.send(chatID, "Не удалось: "+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.answer(cq.ID, note)
|
||||||
|
b.refreshCard(ctx, chatID, msgID, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshCard перечитывает задачу и обновляет карточку на месте.
|
||||||
|
func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id int64) {
|
||||||
|
rd, err := b.reviewer.ReviewData(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
b.log.Warn("telegram: refresh card failed", "download_id", id, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text, kb := b.renderCard(rd)
|
||||||
|
var edit tgbotapi.EditMessageTextConfig
|
||||||
|
if kb != nil {
|
||||||
|
edit = tgbotapi.NewEditMessageTextAndMarkup(chatID, msgID, text, *kb)
|
||||||
|
} else {
|
||||||
|
edit = tgbotapi.NewEditMessageText(chatID, msgID, text)
|
||||||
|
}
|
||||||
|
if _, err := b.api.Send(edit); err != nil {
|
||||||
|
b.log.Warn("telegram: edit card failed", "download_id", id, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Notifier (worker.Notifier) ---
|
||||||
|
|
||||||
|
// Notify шлёт карточку подтверждения/готовности всем доверенным пользователям.
|
||||||
|
func (b *Bot) Notify(ctx context.Context, downloadID int64, event worker.NotifyEvent) {
|
||||||
|
rd, err := b.reviewer.ReviewData(ctx, downloadID)
|
||||||
|
if err != nil {
|
||||||
|
b.log.Warn("telegram: notify review data", "download_id", downloadID, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var text string
|
||||||
|
var kb *tgbotapi.InlineKeyboardMarkup
|
||||||
|
switch event {
|
||||||
|
case worker.EventDone:
|
||||||
|
text = b.renderDone(rd)
|
||||||
|
default:
|
||||||
|
text, kb = b.renderCard(rd)
|
||||||
|
}
|
||||||
|
for chatID := range b.allowed {
|
||||||
|
b.send(chatID, text, kb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Отправка/хелперы ---
|
||||||
|
|
||||||
|
func (b *Bot) send(chatID int64, text string, kb *tgbotapi.InlineKeyboardMarkup) {
|
||||||
|
msg := tgbotapi.NewMessage(chatID, text)
|
||||||
|
msg.DisableWebPagePreview = true
|
||||||
|
if kb != nil {
|
||||||
|
msg.ReplyMarkup = *kb
|
||||||
|
}
|
||||||
|
if _, err := b.api.Send(msg); err != nil {
|
||||||
|
b.log.Warn("telegram: send failed", "chat_id", chatID, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) answer(callbackID, text string) {
|
||||||
|
if _, err := b.api.Request(tgbotapi.NewCallback(callbackID, text)); err != nil {
|
||||||
|
b.log.Warn("telegram: answer callback failed", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) setPending(chatID, id int64) {
|
||||||
|
b.mu.Lock()
|
||||||
|
b.pending[chatID] = id
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) takePending(chatID int64) (int64, bool) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
id, ok := b.pending[chatID]
|
||||||
|
if ok {
|
||||||
|
delete(b.pending, chatID)
|
||||||
|
}
|
||||||
|
return id, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCallback разбирает "action[:id[:value]]".
|
||||||
|
func parseCallback(data string) (action string, id int64, value string) {
|
||||||
|
parts := strings.Split(data, ":")
|
||||||
|
action = parts[0]
|
||||||
|
if len(parts) > 1 {
|
||||||
|
id, _ = strconv.ParseInt(parts[1], 10, 64)
|
||||||
|
}
|
||||||
|
if len(parts) > 2 {
|
||||||
|
value = parts[2]
|
||||||
|
}
|
||||||
|
return action, id, value
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package tgbot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeAPI записывает исходящие Chattable; обновления не нужны (хендлеры зовём
|
||||||
|
// напрямую).
|
||||||
|
type fakeAPI struct {
|
||||||
|
sent []sentMsg
|
||||||
|
edits []sentMsg
|
||||||
|
answers []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type sentMsg struct {
|
||||||
|
chatID int64
|
||||||
|
text string
|
||||||
|
hasKB bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAPI) Send(c tgbotapi.Chattable) (tgbotapi.Message, error) {
|
||||||
|
switch m := c.(type) {
|
||||||
|
case tgbotapi.MessageConfig:
|
||||||
|
f.sent = append(f.sent, sentMsg{m.ChatID, m.Text, m.ReplyMarkup != nil})
|
||||||
|
case tgbotapi.EditMessageTextConfig:
|
||||||
|
f.edits = append(f.edits, sentMsg{m.ChatID, m.Text, m.ReplyMarkup != nil})
|
||||||
|
}
|
||||||
|
return tgbotapi.Message{MessageID: 1}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeAPI) Request(c tgbotapi.Chattable) (*tgbotapi.APIResponse, error) {
|
||||||
|
if cb, ok := c.(tgbotapi.CallbackConfig); ok {
|
||||||
|
f.answers = append(f.answers, cb.Text)
|
||||||
|
}
|
||||||
|
return &tgbotapi.APIResponse{Ok: true}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeAPI) GetUpdatesChan(tgbotapi.UpdateConfig) tgbotapi.UpdatesChannel { return nil }
|
||||||
|
func (f *fakeAPI) StopReceivingUpdates() {}
|
||||||
|
|
||||||
|
type fakeIngestor struct {
|
||||||
|
lastReq ingest.Request
|
||||||
|
res ingest.Result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Result, error) {
|
||||||
|
f.lastReq = req
|
||||||
|
return f.res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeReviewer struct {
|
||||||
|
data *worker.ReviewData
|
||||||
|
applied []int64
|
||||||
|
refined map[int64]string
|
||||||
|
typed map[int64]string
|
||||||
|
deferred []int64
|
||||||
|
canceled []int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReviewer) ReviewData(context.Context, int64) (*worker.ReviewData, error) {
|
||||||
|
return f.data, nil
|
||||||
|
}
|
||||||
|
func (f *fakeReviewer) Apply(_ context.Context, id int64) error {
|
||||||
|
f.applied = append(f.applied, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeReviewer) Refine(_ context.Context, id int64, hint string) error {
|
||||||
|
if f.refined == nil {
|
||||||
|
f.refined = map[int64]string{}
|
||||||
|
}
|
||||||
|
f.refined[id] = hint
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeReviewer) SetType(_ context.Context, id int64, t string) error {
|
||||||
|
if f.typed == nil {
|
||||||
|
f.typed = map[int64]string{}
|
||||||
|
}
|
||||||
|
f.typed[id] = t
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeReviewer) Defer(_ context.Context, id int64) error {
|
||||||
|
f.deferred = append(f.deferred, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeReviewer) Cancel(_ context.Context, id int64) error {
|
||||||
|
f.canceled = append(f.canceled, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func reviewData(state store.State) *worker.ReviewData {
|
||||||
|
s, e := 2, 1
|
||||||
|
return &worker.ReviewData{
|
||||||
|
Download: store.Download{ID: 5, State: state, Context: "Фарго, второй сезон", SourceRef: "magnet:?x"},
|
||||||
|
Recognition: &store.Recognition{
|
||||||
|
Provider: store.NullString("tvdb"), ProviderID: store.NullString("269613"),
|
||||||
|
Reasons: `["неполный пак"]`,
|
||||||
|
},
|
||||||
|
Plan: recognize.Plan{
|
||||||
|
Type: recognize.MediaSeries, Title: "Фарго", Year: 2015,
|
||||||
|
Files: []recognize.PlanFile{{Src: "e1.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e}},
|
||||||
|
},
|
||||||
|
Preview: []layout.Link{
|
||||||
|
{Src: "e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestBot(t *testing.T, allowed []int64) (*Bot, *fakeAPI, *fakeIngestor, *fakeReviewer) {
|
||||||
|
t.Helper()
|
||||||
|
api := &fakeAPI{}
|
||||||
|
ing := &fakeIngestor{res: ingest.Result{DownloadID: 5, State: store.StateDownloading}}
|
||||||
|
rev := &fakeReviewer{data: reviewData(store.StateReview)}
|
||||||
|
b := New(api, ing, rev, Config{AllowedUserIDs: allowed, WebBaseURL: "http://host:8080"},
|
||||||
|
slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
return b, api, ing, rev
|
||||||
|
}
|
||||||
|
|
||||||
|
func msgFrom(userID int64, text string) *tgbotapi.Message {
|
||||||
|
return &tgbotapi.Message{
|
||||||
|
MessageID: 1, From: &tgbotapi.User{ID: userID}, Chat: &tgbotapi.Chat{ID: userID}, Text: text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBot_IngestFromMagnet(t *testing.T) {
|
||||||
|
b, api, ing, _ := newTestBot(t, []int64{7})
|
||||||
|
b.handleMessage(context.Background(), msgFrom(7, "крутой сериал\nmagnet:?xt=urn:btih:ABC"))
|
||||||
|
|
||||||
|
if !strings.HasPrefix(ing.lastReq.Source, "magnet:?xt=urn:btih:ABC") {
|
||||||
|
t.Errorf("source = %q", ing.lastReq.Source)
|
||||||
|
}
|
||||||
|
if ing.lastReq.Context != "крутой сериал" {
|
||||||
|
t.Errorf("context = %q", ing.lastReq.Context)
|
||||||
|
}
|
||||||
|
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #5") {
|
||||||
|
t.Errorf("sent = %+v", api.sent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBot_DeniesUnknownUser(t *testing.T) {
|
||||||
|
b, api, ing, _ := newTestBot(t, []int64{7})
|
||||||
|
b.handleMessage(context.Background(), msgFrom(999, "magnet:?xt=urn:btih:ABC"))
|
||||||
|
|
||||||
|
if len(ing.lastReq.Source) != 0 {
|
||||||
|
t.Error("ingest не должен вызываться для чужого пользователя")
|
||||||
|
}
|
||||||
|
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Доступ запрещён") {
|
||||||
|
t.Errorf("sent = %+v", api.sent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBot_NoMagnet(t *testing.T) {
|
||||||
|
b, api, _, _ := newTestBot(t, []int64{7})
|
||||||
|
b.handleMessage(context.Background(), msgFrom(7, "привет"))
|
||||||
|
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Не вижу magnet") {
|
||||||
|
t.Errorf("sent = %+v", api.sent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBot_RefineViaReply(t *testing.T) {
|
||||||
|
b, _, _, rev := newTestBot(t, []int64{7})
|
||||||
|
// Кнопка «Уточнить» поставила ожидание подсказки для чата 7.
|
||||||
|
b.setPending(7, 5)
|
||||||
|
b.handleMessage(context.Background(), msgFrom(7, "это второй сезон"))
|
||||||
|
|
||||||
|
if rev.refined[5] != "это второй сезон" {
|
||||||
|
t.Errorf("refine = %v", rev.refined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cbFrom(userID int64, data string) *tgbotapi.CallbackQuery {
|
||||||
|
return &tgbotapi.CallbackQuery{
|
||||||
|
ID: "cb", From: &tgbotapi.User{ID: userID}, Data: data,
|
||||||
|
Message: &tgbotapi.Message{MessageID: 99, Chat: &tgbotapi.Chat{ID: userID}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBot_CallbackApply(t *testing.T) {
|
||||||
|
b, api, _, rev := newTestBot(t, []int64{7})
|
||||||
|
b.handleCallback(context.Background(), cbFrom(7, "apply:5"))
|
||||||
|
|
||||||
|
if len(rev.applied) != 1 || rev.applied[0] != 5 {
|
||||||
|
t.Errorf("applied = %v", rev.applied)
|
||||||
|
}
|
||||||
|
if len(api.answers) != 1 {
|
||||||
|
t.Errorf("answers = %v", api.answers)
|
||||||
|
}
|
||||||
|
if len(api.edits) != 1 { // карточка обновлена на месте
|
||||||
|
t.Errorf("edits = %v", api.edits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBot_CallbackType(t *testing.T) {
|
||||||
|
b, _, _, rev := newTestBot(t, []int64{7})
|
||||||
|
b.handleCallback(context.Background(), cbFrom(7, "type:5:movie"))
|
||||||
|
if rev.typed[5] != "movie" {
|
||||||
|
t.Errorf("typed = %v", rev.typed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBot_CallbackRefineSetsPending(t *testing.T) {
|
||||||
|
b, api, _, _ := newTestBot(t, []int64{7})
|
||||||
|
b.handleCallback(context.Background(), cbFrom(7, "refine:5"))
|
||||||
|
|
||||||
|
if id, ok := b.takePending(7); !ok || id != 5 {
|
||||||
|
t.Errorf("pending = %d,%v", id, ok)
|
||||||
|
}
|
||||||
|
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "подсказкой") {
|
||||||
|
t.Errorf("sent = %+v", api.sent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBot_CallbackDeniesUnknown(t *testing.T) {
|
||||||
|
b, _, _, rev := newTestBot(t, []int64{7})
|
||||||
|
b.handleCallback(context.Background(), cbFrom(999, "apply:5"))
|
||||||
|
if len(rev.applied) != 0 {
|
||||||
|
t.Error("чужой колбэк не должен исполняться")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBot_NotifyReview(t *testing.T) {
|
||||||
|
b, api, _, _ := newTestBot(t, []int64{7, 8})
|
||||||
|
b.Notify(context.Background(), 5, worker.EventReview)
|
||||||
|
|
||||||
|
if len(api.sent) != 2 { // обоим доверенным
|
||||||
|
t.Fatalf("sent to %d chats, want 2", len(api.sent))
|
||||||
|
}
|
||||||
|
if !strings.Contains(api.sent[0].text, "Нужно подтверждение #5") {
|
||||||
|
t.Errorf("card text = %q", api.sent[0].text)
|
||||||
|
}
|
||||||
|
if !api.sent[0].hasKB {
|
||||||
|
t.Error("карточка ревью без клавиатуры")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBot_NotifyDone(t *testing.T) {
|
||||||
|
b, api, _, rev := newTestBot(t, []int64{7})
|
||||||
|
rev.data = reviewData(store.StateDone)
|
||||||
|
b.Notify(context.Background(), 5, worker.EventDone)
|
||||||
|
|
||||||
|
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Готово") {
|
||||||
|
t.Errorf("sent = %+v", api.sent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCallback(t *testing.T) {
|
||||||
|
a, id, v := parseCallback("type:5:series")
|
||||||
|
if a != "type" || id != 5 || v != "series" {
|
||||||
|
t.Errorf("got %q %d %q", a, id, v)
|
||||||
|
}
|
||||||
|
a, id, v = parseCallback("apply:9")
|
||||||
|
if a != "apply" || id != 9 || v != "" {
|
||||||
|
t.Errorf("got %q %d %q", a, id, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
// Package tgbot — Telegram-адаптер, парсер сообщений бота и исходящие пинги.
|
|
||||||
//
|
|
||||||
// Заглушка: реализация в фазе Ф5 (см. docs/specs/review-ux.md).
|
|
||||||
package tgbot
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Package tgbot — Telegram-адаптер: приём magnet/пересланных сообщений
|
||||||
|
// торрент-бота, подтверждение раскладки кнопками и исходящие пинги.
|
||||||
|
package tgbot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
magnetRe = regexp.MustCompile(`magnet:\?[^\s]+`)
|
||||||
|
// parenURL — markdown-хвост " (https://…)" в строках сообщения бота.
|
||||||
|
parenURL = regexp.MustCompile(`\s*\(https?://[^)]+\)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// noisePrefixes — начала строк UI торрент-бота, которые в контекст не несём.
|
||||||
|
var noisePrefixes = []string{
|
||||||
|
"Открыть magnet", "или получить .torrent", "Оценить", "Следить",
|
||||||
|
"В закладки", "Добавить в закладки", "cправка", "справка",
|
||||||
|
"[список файлов]", "[список файлов]", "⚡", "сохранённая копия",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseMessage извлекает из текста magnet-ссылку и человекочитаемый контекст
|
||||||
|
// (заголовок релиза без ссылок, команд и UI-мусора бота). ok=false, если
|
||||||
|
// magnet не найден. Текст может быть как «сырым» magnet, так и пересланным
|
||||||
|
// сообщением торрент-бота (формат — см. tmp/examples.md).
|
||||||
|
func ParseMessage(text string) (source, context string, ok bool) {
|
||||||
|
m := magnetRe.FindString(text)
|
||||||
|
if m == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return m, cleanContext(text, m), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanContext оставляет содержательные строки (заголовок, метаданные),
|
||||||
|
// выкидывая ссылки, команды (/...) и UI-строки бота.
|
||||||
|
func cleanContext(text, magnet string) string {
|
||||||
|
var keep []string
|
||||||
|
for _, line := range strings.Split(text, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.Contains(line, magnet) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isNoise(line) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
line = strings.TrimSpace(parenURL.ReplaceAllString(line, ""))
|
||||||
|
if line != "" {
|
||||||
|
keep = append(keep, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(keep, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNoise(line string) bool {
|
||||||
|
if strings.HasPrefix(line, "/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Строки-ссылки и строки рейтинга/команд бота (содержат /g_ /r_ /us_ …).
|
||||||
|
if strings.Contains(line, "hashurl.ru") || strings.Contains(line, "exfreedomist.com") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, p := range noisePrefixes {
|
||||||
|
if strings.HasPrefix(line, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Строки вида "👍: /g_ad035f или 👎🏿: /r_ad035f".
|
||||||
|
if strings.Contains(line, ": /") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package tgbot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// botMessage — реальный блок из tmp/examples.md (Аватар, Книга 2).
|
||||||
|
const botMessage = `[3] #3117703 [rutracker], 2020-01-30 (https://hashurl.ru/eyJhbGc.abc):
|
||||||
|
Аватар: Легенда об Аанге / Avatar: The Last Airbender / Книга 2: Земля / Серии: 1-20 из 20 (Майкл Данте ДиМартино / Michael Dante DiMartino, Брайан Кониецко / Bryan Konietzko) [2006, США, приключения, фэнтези, DVDRip-AVC] Dub + Original
|
||||||
|
|
||||||
|
✅ (проверено) | 4 GB
|
||||||
|
сохранённая копия описания раздачи (https://hashurl.ru/eyJ.def)
|
||||||
|
|
||||||
|
magnet:?xt=urn:btih:7931AA3ED6666746012F5739D099B5BC64D72A16&tr=http%3A%2F%2Fbt2.t-ru.org%2Fann%3Fmagnet&dn=rutracker-topic-3117703
|
||||||
|
|
||||||
|
Открыть magnet в вашем клиенте (https://hashurl.ru/eyJ.ghi)
|
||||||
|
или получить .torrent: /tr_bc8ea
|
||||||
|
|
||||||
|
Оценить:
|
||||||
|
👍: /g_ad035f или 👎🏿: /r_ad035f
|
||||||
|
|
||||||
|
[список файлов] (https://download.exfreedomist.com/files/7931AA3E)
|
||||||
|
|
||||||
|
Следить: /us_bc8ea
|
||||||
|
В закладки: /mka_46d1e
|
||||||
|
|
||||||
|
cправка: /help, настройки: /settings`
|
||||||
|
|
||||||
|
func TestParseMessage_BotForward(t *testing.T) {
|
||||||
|
src, ctx, ok := ParseMessage(botMessage)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("magnet не найден")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(src, "magnet:?xt=urn:btih:7931AA3E") {
|
||||||
|
t.Errorf("magnet = %q", src)
|
||||||
|
}
|
||||||
|
// Контекст несёт заголовок релиза и метаданные.
|
||||||
|
for _, want := range []string{"Avatar: The Last Airbender", "Книга 2", "2006", "DVDRip-AVC", "✅ (проверено) | 4 GB"} {
|
||||||
|
if !strings.Contains(ctx, want) {
|
||||||
|
t.Errorf("контекст без %q:\n%s", want, ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Мусор и ссылки вычищены.
|
||||||
|
for _, bad := range []string{"magnet:", "hashurl.ru", "/tr_", "/g_ad035f", "Следить", "cправка", "список файлов"} {
|
||||||
|
if strings.Contains(ctx, bad) {
|
||||||
|
t.Errorf("контекст содержит мусор %q:\n%s", bad, ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMessage_PlainMagnet(t *testing.T) {
|
||||||
|
src, ctx, ok := ParseMessage("magnet:?xt=urn:btih:ABC123&dn=x")
|
||||||
|
if !ok || src != "magnet:?xt=urn:btih:ABC123&dn=x" {
|
||||||
|
t.Errorf("src = %q, ok = %v", src, ok)
|
||||||
|
}
|
||||||
|
if ctx != "" {
|
||||||
|
t.Errorf("ожидался пустой контекст, got %q", ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMessage_MagnetWithUserText(t *testing.T) {
|
||||||
|
text := "вот сериал, второй сезон\nmagnet:?xt=urn:btih:DEF456"
|
||||||
|
src, ctx, ok := ParseMessage(text)
|
||||||
|
if !ok || !strings.HasPrefix(src, "magnet:?xt=urn:btih:DEF456") {
|
||||||
|
t.Errorf("src = %q", src)
|
||||||
|
}
|
||||||
|
if ctx != "вот сериал, второй сезон" {
|
||||||
|
t.Errorf("context = %q", ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMessage_NoMagnet(t *testing.T) {
|
||||||
|
if _, _, ok := ParseMessage("просто текст без ссылки"); ok {
|
||||||
|
t.Error("ожидалось ok=false без magnet")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package tgbot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// renderCard строит текст и клавиатуру карточки по состоянию задачи.
|
||||||
|
func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboardMarkup) {
|
||||||
|
id := rd.Download.ID
|
||||||
|
state := rd.Download.State
|
||||||
|
|
||||||
|
switch state {
|
||||||
|
case store.StateReview, store.StateDeferred:
|
||||||
|
return b.reviewCard(rd)
|
||||||
|
case store.StateRecognizing:
|
||||||
|
return "⏳ Распознаю #" + itoa(id) + "…", b.webOnly(id)
|
||||||
|
case store.StateLinking:
|
||||||
|
return "⏳ Раскладываю #" + itoa(id) + "…", nil
|
||||||
|
case store.StateDone:
|
||||||
|
return b.renderDone(rd), b.webOnly(id)
|
||||||
|
default:
|
||||||
|
text := fmt.Sprintf("Задача #%d — %s.", id, state)
|
||||||
|
if msg := rd.Download.ErrorMsg.String; msg != "" {
|
||||||
|
text += "\n" + msg
|
||||||
|
}
|
||||||
|
return text, b.webOnly(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboardMarkup) {
|
||||||
|
id := rd.Download.ID
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
fmt.Fprintf(&sb, "🟡 Нужно подтверждение #%d\n", id)
|
||||||
|
if src := contextOrSource(rd); src != "" {
|
||||||
|
fmt.Fprintf(&sb, "Источник: %s\n", shorten(src, 80))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sb, "Похоже на: %s\n", guessLine(rd))
|
||||||
|
if base := baseLine(rd.Recognition); base != "" {
|
||||||
|
fmt.Fprintf(&sb, "База: %s\n", base)
|
||||||
|
}
|
||||||
|
if reasons := rd.Recognition.ReasonList(); len(reasons) > 0 {
|
||||||
|
fmt.Fprintf(&sb, "Причины: %s\n", strings.Join(reasons, " · "))
|
||||||
|
}
|
||||||
|
if n := len(rd.Preview); n > 0 {
|
||||||
|
fmt.Fprintf(&sb, "План: %d файлов → %s", n, tailPath(rd.Preview[0].Dst))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimRight(sb.String(), "\n"), b.reviewKeyboard(rd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMarkup {
|
||||||
|
id := rd.Download.ID
|
||||||
|
sid := itoa(id)
|
||||||
|
|
||||||
|
var row1 []tgbotapi.InlineKeyboardButton
|
||||||
|
if len(rd.Preview) > 0 {
|
||||||
|
row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData("✅ Применить", "apply:"+sid))
|
||||||
|
}
|
||||||
|
row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData("📺↔🎬 Тип", "type:"+sid+":"+oppositeType(string(rd.Plan.Type))))
|
||||||
|
|
||||||
|
row2 := tgbotapi.NewInlineKeyboardRow(
|
||||||
|
tgbotapi.NewInlineKeyboardButtonData("🔁 Уточнить", "refine:"+sid),
|
||||||
|
tgbotapi.NewInlineKeyboardButtonData("🕗 Позже", "defer:"+sid),
|
||||||
|
)
|
||||||
|
|
||||||
|
var row3 []tgbotapi.InlineKeyboardButton
|
||||||
|
if url := b.reviewURL(id); url != "" {
|
||||||
|
row3 = append(row3, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
|
||||||
|
}
|
||||||
|
row3 = append(row3, tgbotapi.NewInlineKeyboardButtonData("❌ Отклонить", "reject:"+sid))
|
||||||
|
|
||||||
|
kb := tgbotapi.NewInlineKeyboardMarkup(row1, row2, row3)
|
||||||
|
return &kb
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderDone — короткое сообщение о готовности.
|
||||||
|
func (b *Bot) renderDone(rd *worker.ReviewData) string {
|
||||||
|
title := rd.Plan.Title
|
||||||
|
if title == "" {
|
||||||
|
title = "#" + itoa(rd.Download.ID)
|
||||||
|
}
|
||||||
|
n := len(rd.Preview)
|
||||||
|
if n == 0 {
|
||||||
|
return fmt.Sprintf("✅ Готово: «%s» разложен.", title)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("✅ Готово: «%s» — разложено файлов: %d.", title, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) webOnly(id int64) *tgbotapi.InlineKeyboardMarkup {
|
||||||
|
url := b.reviewURL(id)
|
||||||
|
if url == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(
|
||||||
|
tgbotapi.NewInlineKeyboardButtonURL("🌐 Открыть в вебе", url),
|
||||||
|
))
|
||||||
|
return &kb
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) reviewURL(id int64) string {
|
||||||
|
if b.webBase == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return b.webBase + "/review/" + itoa(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- мелкие хелперы ---
|
||||||
|
|
||||||
|
func guessLine(rd *worker.ReviewData) string {
|
||||||
|
emoji, kind := "🎬", "фильм"
|
||||||
|
if rd.Plan.Type == "series" {
|
||||||
|
emoji, kind = "📺", "сериал"
|
||||||
|
}
|
||||||
|
title := rd.Plan.Title
|
||||||
|
if title == "" {
|
||||||
|
title = "не распознано"
|
||||||
|
}
|
||||||
|
s := fmt.Sprintf("%s %s «%s»", emoji, kind, title)
|
||||||
|
if rd.Plan.Year != 0 {
|
||||||
|
s += fmt.Sprintf(" (%d)", rd.Plan.Year)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseLine(rec *store.Recognition) string {
|
||||||
|
if rec == nil || !rec.Provider.Valid || rec.Provider.String == "" || rec.Provider.String == "none" {
|
||||||
|
return "нет матча"
|
||||||
|
}
|
||||||
|
if rec.ProviderID.Valid && rec.ProviderID.String != "" {
|
||||||
|
return rec.Provider.String + " " + rec.ProviderID.String
|
||||||
|
}
|
||||||
|
return rec.Provider.String
|
||||||
|
}
|
||||||
|
|
||||||
|
func contextOrSource(rd *worker.ReviewData) string {
|
||||||
|
if c := strings.TrimSpace(rd.Download.Context); c != "" {
|
||||||
|
return firstLine(c)
|
||||||
|
}
|
||||||
|
return rd.Download.SourceRef
|
||||||
|
}
|
||||||
|
|
||||||
|
func oppositeType(t string) string {
|
||||||
|
if t == "series" {
|
||||||
|
return "movie"
|
||||||
|
}
|
||||||
|
return "series"
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLine(s string) string {
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func tailPath(p string) string {
|
||||||
|
dir, file := filepath.Split(p)
|
||||||
|
parent := filepath.Base(strings.TrimRight(dir, "/"))
|
||||||
|
if parent == "." || parent == "/" || parent == "" {
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
return parent + "/" + file
|
||||||
|
}
|
||||||
|
|
||||||
|
func shorten(s string, n int) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(r[:n]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package worker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// discover усыновляет новые раздачи: для каждого торрента с нашей категорией
|
||||||
|
// ИЛИ тегом, чьего infohash ещё нет в БД, заводит задачу downloading. Дальше
|
||||||
|
// её ведёт обычный reconcile. Вызывается под w.mu.
|
||||||
|
//
|
||||||
|
// Корректность при гонке с Ingest (другая горутина): Ingest пишет строку в
|
||||||
|
// БД до добавления в qBit и ставит idempotency_key=infohash, на который есть
|
||||||
|
// UNIQUE-индекс. Поэтому даже если тик и Ingest столкнутся в окне «проверил →
|
||||||
|
// вставляю», второй INSERT упадёт на индексе, и adopt просто пропустит.
|
||||||
|
func (w *Worker) discover(ctx context.Context, torrents []qbt.Torrent) {
|
||||||
|
for _, t := range torrents {
|
||||||
|
if w.tracked(t) {
|
||||||
|
w.adopt(ctx, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tracked — относится ли торрент к jellybit (категория или тег из конфига).
|
||||||
|
func (w *Worker) tracked(t qbt.Torrent) bool {
|
||||||
|
if w.cfg.Category != "" && t.Category == w.cfg.Category {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return hasTag(t.Tags, w.cfg.Tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adopt заводит задачу под торрент, если его ещё не видели.
|
||||||
|
func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
|
||||||
|
infohash := firstInfohash(t)
|
||||||
|
if infohash == "" {
|
||||||
|
return // нечем идентифицировать (напр. ещё metaDL без хэша)
|
||||||
|
}
|
||||||
|
exists, err := w.store.ExistsByInfohash(ctx, infohash)
|
||||||
|
if err != nil {
|
||||||
|
w.log.Warn("discover: exists check failed", "infohash", infohash, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return // уже усыновлён ранее (или обработан) — не трогаем
|
||||||
|
}
|
||||||
|
|
||||||
|
d := &store.Download{
|
||||||
|
SourceType: store.SourceMagnet,
|
||||||
|
SourceRef: "magnet:?xt=urn:btih:" + infohash,
|
||||||
|
Infohash: store.NullString(infohash),
|
||||||
|
IdempotencyKey: store.NullString(infohash),
|
||||||
|
State: store.StateDownloading,
|
||||||
|
}
|
||||||
|
id, err := w.store.CreateDownload(ctx, d)
|
||||||
|
if err != nil {
|
||||||
|
// Гонка: Ingest/другой тик мог вставить запись между проверкой и
|
||||||
|
// вставкой — UNIQUE-индекс это отсёк. Если запись появилась, всё ок.
|
||||||
|
if ex, _ := w.store.ExistsByInfohash(ctx, infohash); ex {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.log.Error("discover: adopt failed", "infohash", infohash, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.log.Info("discover: adopted torrent",
|
||||||
|
"download_id", id, "infohash", infohash, "name", t.Name,
|
||||||
|
"category", t.Category, "tags", t.Tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasTag сообщает, есть ли tag среди списка тегов qBit (через запятую).
|
||||||
|
func hasTag(tags, tag string) bool {
|
||||||
|
if tag == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, x := range strings.Split(tags, ",") {
|
||||||
|
if strings.TrimSpace(x) == tag {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstInfohash возвращает первый непустой infohash торрента (нижний регистр).
|
||||||
|
func firstInfohash(t qbt.Torrent) string {
|
||||||
|
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
|
||||||
|
if h != "" {
|
||||||
|
return strings.ToLower(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package worker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ihDisc = "7931aa3ed6666746012f5739d099b5bc64d72a16"
|
||||||
|
|
||||||
|
func emptyStore() *fakeStore {
|
||||||
|
return &fakeStore{downloads: map[int64]*store.Download{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findByInfohash возвращает усыновлённую задачу по infohash.
|
||||||
|
func findByInfohash(st *fakeStore, infohash string) *store.Download {
|
||||||
|
for _, d := range st.downloads {
|
||||||
|
if d.Infohash.String == infohash {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscover_AdoptsByCategory(t *testing.T) {
|
||||||
|
st := emptyStore()
|
||||||
|
w := newTestWorker(st, &fakeQbt{})
|
||||||
|
w.discover(context.Background(), []qbt.Torrent{
|
||||||
|
{Hash: ihDisc, Name: "Avatar", Category: "jellybit", State: "stalledUP"},
|
||||||
|
})
|
||||||
|
|
||||||
|
d := findByInfohash(st, ihDisc)
|
||||||
|
if d == nil {
|
||||||
|
t.Fatal("раздача с категорией jellybit не усыновлена")
|
||||||
|
}
|
||||||
|
if d.State != store.StateDownloading || d.SourceType != store.SourceMagnet {
|
||||||
|
t.Errorf("adopted = %+v", d)
|
||||||
|
}
|
||||||
|
if d.IdempotencyKey.String != ihDisc {
|
||||||
|
t.Errorf("idempotency_key = %q", d.IdempotencyKey.String)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscover_AdoptsByTag(t *testing.T) {
|
||||||
|
st := emptyStore()
|
||||||
|
w := newTestWorker(st, &fakeQbt{})
|
||||||
|
w.cfg.Tag = "jellybit"
|
||||||
|
// Категория чужая, но тег наш — усыновляем (не трогая категорию).
|
||||||
|
w.discover(context.Background(), []qbt.Torrent{
|
||||||
|
{Hash: ihDisc, Name: "Fargo", Category: "movies", Tags: "hd, jellybit, rus", State: "uploading"},
|
||||||
|
})
|
||||||
|
if findByInfohash(st, ihDisc) == nil {
|
||||||
|
t.Fatal("раздача с тегом jellybit не усыновлена")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscover_SkipsUntracked(t *testing.T) {
|
||||||
|
st := emptyStore()
|
||||||
|
w := newTestWorker(st, &fakeQbt{})
|
||||||
|
w.cfg.Tag = "jellybit"
|
||||||
|
w.discover(context.Background(), []qbt.Torrent{
|
||||||
|
{Hash: ihDisc, Category: "movies", Tags: "hd, rus"},
|
||||||
|
})
|
||||||
|
if len(st.downloads) != 0 {
|
||||||
|
t.Errorf("чужая раздача не должна усыновляться: %+v", st.downloads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscover_SkipsExisting(t *testing.T) {
|
||||||
|
st := emptyStore()
|
||||||
|
// Уже есть задача (напр. терминальная done) — не переусыновляем.
|
||||||
|
st.downloads[1] = &store.Download{
|
||||||
|
ID: 1, State: store.StateDone, Infohash: store.NullString(ihDisc),
|
||||||
|
}
|
||||||
|
w := newTestWorker(st, &fakeQbt{})
|
||||||
|
w.discover(context.Background(), []qbt.Torrent{
|
||||||
|
{Hash: ihDisc, Category: "jellybit"},
|
||||||
|
})
|
||||||
|
if len(st.downloads) != 1 {
|
||||||
|
t.Errorf("существующий infohash не должен порождать новую задачу: %d", len(st.downloads))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscover_SkipsNoInfohash(t *testing.T) {
|
||||||
|
st := emptyStore()
|
||||||
|
w := newTestWorker(st, &fakeQbt{})
|
||||||
|
w.discover(context.Background(), []qbt.Torrent{{Category: "jellybit"}})
|
||||||
|
if len(st.downloads) != 0 {
|
||||||
|
t.Error("без infohash усыновлять нечего")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPoll_AdoptsAndCompletes — сценарий пользователя целиком: помеченная и
|
||||||
|
// уже скачанная раздача за один тик усыновляется и доходит до completed.
|
||||||
|
func TestPoll_AdoptsAndCompletes(t *testing.T) {
|
||||||
|
st := emptyStore()
|
||||||
|
qb := &fakeQbt{torrents: []qbt.Torrent{
|
||||||
|
{Hash: ihDisc, Name: "Avatar", Category: "other", Tags: "jellybit", State: "stalledUP"},
|
||||||
|
}}
|
||||||
|
w := newTestWorker(st, qb)
|
||||||
|
w.cfg.Tag = "jellybit"
|
||||||
|
|
||||||
|
if err := w.Poll(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Poll: %v", err)
|
||||||
|
}
|
||||||
|
d := findByInfohash(st, ihDisc)
|
||||||
|
if d == nil {
|
||||||
|
t.Fatal("не усыновлено")
|
||||||
|
}
|
||||||
|
if d.State != store.StateCompleted {
|
||||||
|
t.Errorf("state = %q, want completed (готовая раздача)", d.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasTag(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
tags, tag string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"jellybit", "jellybit", true},
|
||||||
|
{"hd, jellybit, rus", "jellybit", true},
|
||||||
|
{"hd,rus", "jellybit", false},
|
||||||
|
{"jellybit-extra", "jellybit", false},
|
||||||
|
{"", "jellybit", false},
|
||||||
|
{"jellybit", "", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := hasTag(c.tags, c.tag); got != c.want {
|
||||||
|
t.Errorf("hasTag(%q,%q) = %v, want %v", c.tags, c.tag, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirstInfohash(t *testing.T) {
|
||||||
|
if got := firstInfohash(qbt.Torrent{Hash: "ABC"}); got != "abc" {
|
||||||
|
t.Errorf("got %q", got)
|
||||||
|
}
|
||||||
|
if got := firstInfohash(qbt.Torrent{InfohashV2: "DEF"}); got != "def" {
|
||||||
|
t.Errorf("got %q", got)
|
||||||
|
}
|
||||||
|
if got := firstInfohash(qbt.Torrent{}); got != "" {
|
||||||
|
t.Errorf("got %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+211
-31
@@ -7,9 +7,11 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
@@ -19,6 +21,10 @@ import (
|
|||||||
const (
|
const (
|
||||||
ovrMediaType = "media_type"
|
ovrMediaType = "media_type"
|
||||||
ovrIgnoredFiles = "ignored_files"
|
ovrIgnoredFiles = "ignored_files"
|
||||||
|
ovrProvider = "provider" // выбранная база ("none" = без базы)
|
||||||
|
ovrProviderID = "provider_id" // id в выбранной базе
|
||||||
|
ovrTitle = "title" // запиненное каноническое название
|
||||||
|
ovrYear = "year" // запиненный год
|
||||||
)
|
)
|
||||||
|
|
||||||
// recognizePending распознаёт завершённые загрузки и перезапускает те, что
|
// recognizePending распознаёт завершённые загрузки и перезапускает те, что
|
||||||
@@ -75,14 +81,14 @@ func (w *Worker) recognizeOne(ctx context.Context, id int64) {
|
|||||||
// относительных путей файлов в абсолютные при раскладке.
|
// относительных путей файлов в абсолютные при раскладке.
|
||||||
func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.Result, string, error) {
|
func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.Result, string, error) {
|
||||||
if !d.Infohash.Valid {
|
if !d.Infohash.Valid {
|
||||||
return recognize.Result{}, "", fmt.Errorf("нет infohash")
|
return recognize.Result{}, "", fmt.Errorf("no infohash")
|
||||||
}
|
}
|
||||||
t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
|
t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return recognize.Result{}, "", err
|
return recognize.Result{}, "", err
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
return recognize.Result{}, "", fmt.Errorf("торрент не найден в qBittorrent")
|
return recognize.Result{}, "", fmt.Errorf("torrent not found in qBittorrent")
|
||||||
}
|
}
|
||||||
files, err := w.qbt.Files(ctx, t.Hash)
|
files, err := w.qbt.Files(ctx, t.Hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -103,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
|
||||||
@@ -158,10 +165,17 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
|
|||||||
"download_id", id, "state", d.State)
|
"download_id", id, "state", d.State)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := w.store.CreateRecognition(ctx, rec, res.Decision.Reasons); err != nil {
|
recID, err := w.store.CreateRecognition(ctx, rec, res.Decision.Reasons)
|
||||||
|
if err != nil {
|
||||||
w.log.Error("recognize: persist", "download_id", id, "err", err)
|
w.log.Error("recognize: persist", "download_id", id, "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Кандидаты базы — для ручного выбора в review.
|
||||||
|
if cands := toStoreCandidates(recID, res.Candidates); len(cands) > 0 {
|
||||||
|
if err := w.store.CreateCandidates(ctx, cands); err != nil {
|
||||||
|
w.log.Warn("recognize: persist candidates", "download_id", id, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Авто-раскладка при подтверждённом матче и чистой валидации (Ф4);
|
// Авто-раскладка при подтверждённом матче и чистой валидации (Ф4);
|
||||||
// иначе — review. Раскладчик может быть не сконфигурирован.
|
// иначе — review. Раскладчик может быть не сконфигурирован.
|
||||||
@@ -195,7 +209,7 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
|
|||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
if w.layouter == nil {
|
if w.layouter == nil {
|
||||||
return fmt.Errorf("apply: раскладчик не сконфигурирован")
|
return fmt.Errorf("apply: layouter not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
d, err := w.store.GetDownload(ctx, id)
|
d, err := w.store.GetDownload(ctx, id)
|
||||||
@@ -203,7 +217,7 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
|
|||||||
return fmt.Errorf("apply: %w", err)
|
return fmt.Errorf("apply: %w", err)
|
||||||
}
|
}
|
||||||
if d.State != store.StateReview && d.State != store.StateDeferred {
|
if d.State != store.StateReview && d.State != store.StateDeferred {
|
||||||
return fmt.Errorf("apply: задача %d в состоянии %s (ожидалось review/deferred)", id, d.State)
|
return fmt.Errorf("apply: download %d is in state %s (expected review/deferred)", id, d.State)
|
||||||
}
|
}
|
||||||
|
|
||||||
plan, tag, err := w.effectivePlan(ctx, id)
|
plan, tag, err := w.effectivePlan(ctx, id)
|
||||||
@@ -212,11 +226,11 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
|
|||||||
}
|
}
|
||||||
t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
|
t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return fmt.Errorf("apply: торрент не найден: %v", err)
|
return fmt.Errorf("apply: torrent not found: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -229,7 +243,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
|||||||
links, err := w.layouter.BuildLinks(toLayoutPlan(plan, savePath, providerTag))
|
links, err := w.layouter.BuildLinks(toLayoutPlan(plan, savePath, providerTag))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.transition(ctx, *d, store.StateReview, "build", err.Error())
|
w.transition(ctx, *d, store.StateReview, "build", err.Error())
|
||||||
return fmt.Errorf("построение ссылок: %w", err)
|
return fmt.Errorf("build links: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
batch := w.newID()
|
batch := w.newID()
|
||||||
@@ -249,7 +263,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
|||||||
}
|
}
|
||||||
if len(fl) > 0 {
|
if len(fl) > 0 {
|
||||||
if err := w.store.CreateFileLinks(ctx, fl); err != nil {
|
if err := w.store.CreateFileLinks(ctx, fl); err != nil {
|
||||||
return fmt.Errorf("запись ссылок: %w", err)
|
return fmt.Errorf("persist links: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,7 +285,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
|||||||
func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
|
func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
|
||||||
hint = strings.TrimSpace(hint)
|
hint = strings.TrimSpace(hint)
|
||||||
if hint == "" {
|
if hint == "" {
|
||||||
return fmt.Errorf("refine: пустая подсказка")
|
return fmt.Errorf("refine: empty hint")
|
||||||
}
|
}
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
@@ -283,6 +297,7 @@ func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
|
|||||||
if err := w.store.AddHint(ctx, id, hint); err != nil {
|
if err := w.store.AddHint(ctx, id, hint); err != nil {
|
||||||
return fmt.Errorf("refine: %w", err)
|
return fmt.Errorf("refine: %w", err)
|
||||||
}
|
}
|
||||||
|
w.log.Info("review: hint added, re-recognizing", "download_id", id, "hint", hint)
|
||||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -291,7 +306,7 @@ func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
|
|||||||
// — чтобы LLM пересобрал роли файлов под новый тип.
|
// — чтобы LLM пересобрал роли файлов под новый тип.
|
||||||
func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error {
|
func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error {
|
||||||
if mediaType != string(recognize.MediaMovie) && mediaType != string(recognize.MediaSeries) {
|
if mediaType != string(recognize.MediaMovie) && mediaType != string(recognize.MediaSeries) {
|
||||||
return fmt.Errorf("set type: недопустимый тип %q", mediaType)
|
return fmt.Errorf("set type: invalid type %q", mediaType)
|
||||||
}
|
}
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
@@ -319,7 +334,7 @@ func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error
|
|||||||
func (w *Worker) IgnoreFile(ctx context.Context, id int64, src string) error {
|
func (w *Worker) IgnoreFile(ctx context.Context, id int64, src string) error {
|
||||||
src = strings.TrimSpace(src)
|
src = strings.TrimSpace(src)
|
||||||
if src == "" {
|
if src == "" {
|
||||||
return fmt.Errorf("ignore: пустой путь")
|
return fmt.Errorf("ignore: empty path")
|
||||||
}
|
}
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
@@ -339,6 +354,7 @@ func (w *Worker) IgnoreFile(ctx context.Context, id int64, src string) error {
|
|||||||
if err := w.store.SetOverride(ctx, id, ovrIgnoredFiles, string(b)); err != nil {
|
if err := w.store.SetOverride(ctx, id, ovrIgnoredFiles, string(b)); err != nil {
|
||||||
return fmt.Errorf("ignore: %w", err)
|
return fmt.Errorf("ignore: %w", err)
|
||||||
}
|
}
|
||||||
|
w.log.Info("review: file ignored", "download_id", id, "src", src)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,7 +368,7 @@ func (w *Worker) Defer(ctx context.Context, id int64) error {
|
|||||||
return fmt.Errorf("defer: %w", err)
|
return fmt.Errorf("defer: %w", err)
|
||||||
}
|
}
|
||||||
if d.State.IsTerminal() {
|
if d.State.IsTerminal() {
|
||||||
return fmt.Errorf("defer: задача %d терминальна (%s)", id, d.State)
|
return fmt.Errorf("defer: download %d is terminal (%s)", id, d.State)
|
||||||
}
|
}
|
||||||
w.transition(ctx, *d, store.StateDeferred, "", "")
|
w.transition(ctx, *d, store.StateDeferred, "", "")
|
||||||
return nil
|
return nil
|
||||||
@@ -364,7 +380,7 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
|
|||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
if w.layouter == nil {
|
if w.layouter == nil {
|
||||||
return fmt.Errorf("undo: раскладчик не сконфигурирован")
|
return fmt.Errorf("undo: layouter not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
d, err := w.store.GetDownload(ctx, id)
|
d, err := w.store.GetDownload(ctx, id)
|
||||||
@@ -372,14 +388,14 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
|
|||||||
return fmt.Errorf("undo: %w", err)
|
return fmt.Errorf("undo: %w", err)
|
||||||
}
|
}
|
||||||
if d.State != store.StateDone {
|
if d.State != store.StateDone {
|
||||||
return fmt.Errorf("undo: задача %d в состоянии %s (ожидалось done)", id, d.State)
|
return fmt.Errorf("undo: download %d is in state %s (expected done)", id, d.State)
|
||||||
}
|
}
|
||||||
batch, err := w.store.LatestBatchID(ctx, id)
|
batch, err := w.store.LatestBatchID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("undo: %w", err)
|
return fmt.Errorf("undo: %w", err)
|
||||||
}
|
}
|
||||||
if batch == "" {
|
if batch == "" {
|
||||||
return fmt.Errorf("undo: нечего откатывать")
|
return fmt.Errorf("undo: nothing to revert")
|
||||||
}
|
}
|
||||||
rows, err := w.store.ListFileLinksByBatch(ctx, batch)
|
rows, err := w.store.ListFileLinksByBatch(ctx, batch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -408,19 +424,113 @@ func (w *Worker) requireReviewable(ctx context.Context, id int64, op string) (*s
|
|||||||
return nil, fmt.Errorf("%s: %w", op, err)
|
return nil, fmt.Errorf("%s: %w", op, err)
|
||||||
}
|
}
|
||||||
if d.State != store.StateReview && d.State != store.StateDeferred {
|
if d.State != store.StateReview && d.State != store.StateDeferred {
|
||||||
return nil, fmt.Errorf("%s: задача %d в состоянии %s (ожидалось review/deferred)", op, id, d.State)
|
return nil, fmt.Errorf("%s: download %d is in state %s (expected review/deferred)", op, id, d.State)
|
||||||
}
|
}
|
||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Выбор базы метаданных (пиннинг; остаёмся в review, применяет человек) ---
|
||||||
|
|
||||||
|
// ChooseCandidate пиннит выбранного кандидата базы как override (провайдер,
|
||||||
|
// id, каноническое имя/год). Раскладку не запускает — превью обновится, а
|
||||||
|
// человек подтвердит «Применить».
|
||||||
|
func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID int64) error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
if _, err := w.requireReviewable(ctx, id, "choose candidate"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rec, err := w.store.GetCurrentRecognition(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("choose candidate: %w", err)
|
||||||
|
}
|
||||||
|
cand, err := w.store.GetCandidate(ctx, candidateID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("choose candidate: %w", err)
|
||||||
|
}
|
||||||
|
if rec == nil || cand == nil || cand.RecognitionID != rec.ID {
|
||||||
|
return fmt.Errorf("choose candidate: candidate %d does not belong to the current recognition", candidateID)
|
||||||
|
}
|
||||||
|
|
||||||
|
pins := map[string]string{ovrProvider: cand.Provider, ovrProviderID: cand.ProviderID}
|
||||||
|
if cand.Title.Valid && cand.Title.String != "" {
|
||||||
|
pins[ovrTitle] = cand.Title.String
|
||||||
|
}
|
||||||
|
if cand.Year.Valid {
|
||||||
|
pins[ovrYear] = strconv.FormatInt(cand.Year.Int64, 10)
|
||||||
|
}
|
||||||
|
for field, value := range pins {
|
||||||
|
if err := w.store.SetOverride(ctx, id, field, value); err != nil {
|
||||||
|
return fmt.Errorf("choose candidate: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := w.store.SetCandidateChosen(ctx, rec.ID, candidateID); err != nil {
|
||||||
|
return fmt.Errorf("choose candidate: %w", err)
|
||||||
|
}
|
||||||
|
w.log.Info("review: candidate chosen",
|
||||||
|
"download_id", id, "provider", cand.Provider, "provider_id", cand.ProviderID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetProviderID пиннит провайдера и id вручную (без выбора из списка).
|
||||||
|
func (w *Worker) SetProviderID(ctx context.Context, id int64, provider, providerID string) error {
|
||||||
|
provider = strings.TrimSpace(strings.ToLower(provider))
|
||||||
|
providerID = strings.TrimSpace(providerID)
|
||||||
|
switch provider {
|
||||||
|
case "tmdb", "tvdb", "imdb":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("set provider: invalid provider %q (tmdb/tvdb/imdb)", provider)
|
||||||
|
}
|
||||||
|
if providerID == "" {
|
||||||
|
return fmt.Errorf("set provider: empty id")
|
||||||
|
}
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
if _, err := w.requireReviewable(ctx, id, "set provider"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := w.store.SetOverride(ctx, id, ovrProvider, provider); err != nil {
|
||||||
|
return fmt.Errorf("set provider: %w", err)
|
||||||
|
}
|
||||||
|
if err := w.store.SetOverride(ctx, id, ovrProviderID, providerID); err != nil {
|
||||||
|
return fmt.Errorf("set provider: %w", err)
|
||||||
|
}
|
||||||
|
w.log.Info("review: provider set manually",
|
||||||
|
"download_id", id, "provider", provider, "provider_id", providerID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearProvider — «без базы»: снимает матч (тег папки не ставится).
|
||||||
|
func (w *Worker) ClearProvider(ctx context.Context, id int64) error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
if _, err := w.requireReviewable(ctx, id, "clear provider"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := w.store.SetOverride(ctx, id, ovrProvider, "none"); err != nil {
|
||||||
|
return fmt.Errorf("clear provider: %w", err)
|
||||||
|
}
|
||||||
|
if err := w.store.SetOverride(ctx, id, ovrProviderID, ""); err != nil {
|
||||||
|
return fmt.Errorf("clear provider: %w", err)
|
||||||
|
}
|
||||||
|
w.log.Info("review: provider cleared (no metadata base)", "download_id", id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- Данные для экрана ревью ---
|
// --- Данные для экрана ревью ---
|
||||||
|
|
||||||
// ReviewData — всё, что нужно транспорту для отрисовки ревью.
|
// ReviewData — всё, что нужно транспорту для отрисовки ревью.
|
||||||
type ReviewData struct {
|
type ReviewData struct {
|
||||||
Download store.Download
|
Download store.Download
|
||||||
Recognition *store.Recognition
|
Recognition *store.Recognition
|
||||||
Plan recognize.Plan // эффективный (с применёнными правками)
|
Plan recognize.Plan // эффективный (с применёнными правками)
|
||||||
Preview []layout.Link // целевые пути (Src — относительный, для показа)
|
Preview []layout.Link // целевые пути (Src — относительный, для показа)
|
||||||
|
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
|
||||||
|
Provider string // эффективный провайдер (с учётом выбора)
|
||||||
|
ProviderID string // эффективный id в базе
|
||||||
Hints []string
|
Hints []string
|
||||||
Overrides map[string]string
|
Overrides map[string]string
|
||||||
}
|
}
|
||||||
@@ -444,18 +554,35 @@ func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error)
|
|||||||
return nil, fmt.Errorf("review data: %w", err)
|
return nil, fmt.Errorf("review data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rd := &ReviewData{Download: *d, Recognition: rec, Hints: hints, Overrides: overrides}
|
prov, pid := effectiveProvider(rec, overrides)
|
||||||
|
rd := &ReviewData{
|
||||||
|
Download: *d, Recognition: rec, Hints: hints, Overrides: overrides,
|
||||||
|
Provider: prov, ProviderID: pid,
|
||||||
|
}
|
||||||
|
if rec != nil {
|
||||||
|
if cands, cerr := w.store.ListCandidatesByRecognition(ctx, rec.ID); cerr == nil {
|
||||||
|
rd.Candidates = cands
|
||||||
|
} else {
|
||||||
|
w.log.Debug("review data: list candidates failed (skipped)",
|
||||||
|
"download_id", id, "err", cerr)
|
||||||
|
}
|
||||||
|
}
|
||||||
if rec != nil && rec.Plan.Valid {
|
if rec != nil && rec.Plan.Valid {
|
||||||
var plan recognize.Plan
|
var plan recognize.Plan
|
||||||
if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err == nil {
|
if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err != nil {
|
||||||
|
w.log.Warn("review data: unmarshal plan failed", "download_id", id, "err", err)
|
||||||
|
} else {
|
||||||
plan = applyOverrides(plan, overrides)
|
plan = applyOverrides(plan, overrides)
|
||||||
rd.Plan = plan
|
rd.Plan = plan
|
||||||
// Превью строим по относительным путям с provider-тегом; ошибку
|
// Превью строим по относительным путям с provider-тегом; ошибку
|
||||||
// игнорируем — просто покажем причины без превью.
|
// логируем на Debug — просто покажем причины без превью.
|
||||||
if w.layouter != nil {
|
if w.layouter != nil {
|
||||||
tag := providerTag(rec.Provider.String, rec.ProviderID.String)
|
tag := providerTag(prov, pid)
|
||||||
if links, lerr := w.layouter.BuildLinks(toLayoutPlan(plan, "", tag)); lerr == nil {
|
if links, lerr := w.layouter.BuildLinks(toLayoutPlan(plan, "", tag)); lerr == nil {
|
||||||
rd.Preview = links
|
rd.Preview = links
|
||||||
|
} else {
|
||||||
|
w.log.Debug("review data: build preview failed (skipped)",
|
||||||
|
"download_id", id, "err", lerr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -471,28 +598,37 @@ func (w *Worker) effectivePlan(ctx context.Context, id int64) (recognize.Plan, s
|
|||||||
return recognize.Plan{}, "", err
|
return recognize.Plan{}, "", err
|
||||||
}
|
}
|
||||||
if rec == nil || !rec.Plan.Valid {
|
if rec == nil || !rec.Plan.Valid {
|
||||||
return recognize.Plan{}, "", fmt.Errorf("нет плана распознавания")
|
return recognize.Plan{}, "", fmt.Errorf("no recognition plan")
|
||||||
}
|
}
|
||||||
var plan recognize.Plan
|
var plan recognize.Plan
|
||||||
if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err != nil {
|
if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err != nil {
|
||||||
return recognize.Plan{}, "", fmt.Errorf("разбор плана: %w", err)
|
return recognize.Plan{}, "", fmt.Errorf("parse plan: %w", err)
|
||||||
}
|
}
|
||||||
overrides, err := w.store.ListOverrides(ctx, id)
|
overrides, err := w.store.ListOverrides(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return recognize.Plan{}, "", err
|
return recognize.Plan{}, "", err
|
||||||
}
|
}
|
||||||
tag := providerTag(rec.Provider.String, rec.ProviderID.String)
|
prov, pid := effectiveProvider(rec, overrides)
|
||||||
return applyOverrides(plan, overrides), tag, nil
|
return applyOverrides(plan, overrides), providerTag(prov, pid), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Хелперы преобразования ---
|
// --- Хелперы преобразования ---
|
||||||
|
|
||||||
// applyOverrides применяет ручные правки к плану: форсит тип и помечает
|
// applyOverrides применяет ручные правки к плану: форсит тип, каноническое
|
||||||
// игнорируемые файлы ролью ignore (их раскладка пропустит).
|
// имя/год (из выбранного кандидата базы) и помечает игнорируемые файлы ролью
|
||||||
|
// ignore (их раскладка пропустит).
|
||||||
func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.Plan {
|
func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.Plan {
|
||||||
if mt := overrides[ovrMediaType]; mt == string(recognize.MediaMovie) || mt == string(recognize.MediaSeries) {
|
if mt := overrides[ovrMediaType]; mt == string(recognize.MediaMovie) || mt == string(recognize.MediaSeries) {
|
||||||
plan.Type = recognize.MediaType(mt)
|
plan.Type = recognize.MediaType(mt)
|
||||||
}
|
}
|
||||||
|
if t := overrides[ovrTitle]; t != "" {
|
||||||
|
plan.Title = t
|
||||||
|
}
|
||||||
|
if y := overrides[ovrYear]; y != "" {
|
||||||
|
if year, err := strconv.Atoi(y); err == nil {
|
||||||
|
plan.Year = year
|
||||||
|
}
|
||||||
|
}
|
||||||
ignored := parseIgnored(overrides[ovrIgnoredFiles])
|
ignored := parseIgnored(overrides[ovrIgnoredFiles])
|
||||||
if len(ignored) > 0 {
|
if len(ignored) > 0 {
|
||||||
for i := range plan.Files {
|
for i := range plan.Files {
|
||||||
@@ -504,6 +640,48 @@ func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.
|
|||||||
return plan
|
return plan
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// effectiveProvider возвращает провайдера и id для тега папки с учётом
|
||||||
|
// ручного выбора: запиненный override перекрывает распознанный матч.
|
||||||
|
// override "none" означает явный отказ от базы.
|
||||||
|
func effectiveProvider(rec *store.Recognition, overrides map[string]string) (provider, id string) {
|
||||||
|
if p, ok := overrides[ovrProvider]; ok {
|
||||||
|
return p, overrides[ovrProviderID]
|
||||||
|
}
|
||||||
|
if rec != nil {
|
||||||
|
return rec.Provider.String, rec.ProviderID.String
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// toStoreCandidates переводит кандидатов распознавания в строки БД,
|
||||||
|
// подставляя тег-предпочтительный provider/id (внешний из TVMaze и т.п.).
|
||||||
|
func toStoreCandidates(recognitionID int64, cands []metadata.Candidate) []store.MetadataCandidate {
|
||||||
|
out := make([]store.MetadataCandidate, 0, len(cands))
|
||||||
|
for _, c := range cands {
|
||||||
|
prov, id := recognize.CandidateTag(c)
|
||||||
|
mc := store.MetadataCandidate{
|
||||||
|
RecognitionID: recognitionID,
|
||||||
|
Provider: prov,
|
||||||
|
ProviderID: id,
|
||||||
|
Title: store.NullString(c.Title),
|
||||||
|
}
|
||||||
|
if c.Year != 0 {
|
||||||
|
mc.Year = sql.NullInt64{Int64: int64(c.Year), Valid: true}
|
||||||
|
}
|
||||||
|
out = append(out, mc)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderTag — экспорт providerTag для диагностических команд (CLI
|
||||||
|
// `jellybit recognize --dry-run`).
|
||||||
|
func ProviderTag(provider, id string) string { return providerTag(provider, id) }
|
||||||
|
|
||||||
|
// ToLayoutPlan — экспорт toLayoutPlan для диагностических команд.
|
||||||
|
func ToLayoutPlan(p recognize.Plan, srcPrefix, providerTag string) layout.Plan {
|
||||||
|
return toLayoutPlan(p, srcPrefix, providerTag)
|
||||||
|
}
|
||||||
|
|
||||||
// providerTag строит тег папки для Jellyfin из провайдера и id: "tmdbid-…"
|
// providerTag строит тег папки для Jellyfin из провайдера и id: "tmdbid-…"
|
||||||
// / "tvdbid-…". Пустой id (нет матча) → пустой тег.
|
// / "tvdbid-…". Пустой id (нет матча) → пустой тег.
|
||||||
func providerTag(provider, id string) string {
|
func providerTag(provider, id string) string {
|
||||||
@@ -515,6 +693,8 @@ func providerTag(provider, id string) string {
|
|||||||
return "tmdbid-" + id
|
return "tmdbid-" + id
|
||||||
case "tvdb":
|
case "tvdb":
|
||||||
return "tvdbid-" + id
|
return "tvdbid-" + id
|
||||||
|
case "imdb":
|
||||||
|
return "imdbid-" + id
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,26 +2,85 @@ package worker
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// recordingNotifier ловит события пинга (Notify асинхронен — через канал).
|
||||||
|
type notifyEvent struct {
|
||||||
|
id int64
|
||||||
|
ev NotifyEvent
|
||||||
|
}
|
||||||
|
type recordingNotifier struct{ ch chan notifyEvent }
|
||||||
|
|
||||||
|
func (n *recordingNotifier) Notify(_ context.Context, id int64, ev NotifyEvent) {
|
||||||
|
n.ch <- notifyEvent{id, ev}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitNotify(t *testing.T, n *recordingNotifier) notifyEvent {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case e := <-n.ch:
|
||||||
|
return e
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("пинг не пришёл")
|
||||||
|
return notifyEvent{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotifier_FiresOnReview(t *testing.T) {
|
||||||
|
st := newMemStore()
|
||||||
|
st.put(completedDownload(1))
|
||||||
|
qb := &fakeQbt{
|
||||||
|
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}},
|
||||||
|
files: []qbt.File{{Name: "Show/e1.mkv", Size: 1}},
|
||||||
|
}
|
||||||
|
w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil)
|
||||||
|
n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
|
||||||
|
w.SetNotifier(n)
|
||||||
|
|
||||||
|
w.recognizeOne(context.Background(), 1)
|
||||||
|
|
||||||
|
e := waitNotify(t, n)
|
||||||
|
if e.id != 1 || e.ev != EventReview {
|
||||||
|
t.Errorf("event = %+v, want {1 review}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotifier_FiresOnDone(t *testing.T) {
|
||||||
|
f := newApplyFixture(t, seriesResult().Plan)
|
||||||
|
n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
|
||||||
|
f.w.SetNotifier(n)
|
||||||
|
|
||||||
|
if err := f.w.Apply(context.Background(), 1); err != nil {
|
||||||
|
t.Fatalf("Apply: %v", err)
|
||||||
|
}
|
||||||
|
e := waitNotify(t, n)
|
||||||
|
if e.id != 1 || e.ev != EventDone {
|
||||||
|
t.Errorf("event = %+v, want {1 done}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// memStore — полноценный in-memory store для тестов Ф3.
|
// memStore — полноценный in-memory store для тестов Ф3.
|
||||||
type memStore struct {
|
type memStore struct {
|
||||||
downloads map[int64]*store.Download
|
downloads map[int64]*store.Download
|
||||||
recs []*store.Recognition
|
recs []*store.Recognition
|
||||||
hints map[int64][]string
|
hints map[int64][]string
|
||||||
overrides map[int64]map[string]string
|
overrides map[int64]map[string]string
|
||||||
links []store.FileLink
|
links []store.FileLink
|
||||||
|
candidates []store.MetadataCandidate
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMemStore() *memStore {
|
func newMemStore() *memStore {
|
||||||
@@ -46,6 +105,23 @@ func (m *memStore) ListDownloadsByState(_ context.Context, states ...store.State
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *memStore) ExistsByInfohash(_ context.Context, infohash string) (bool, error) {
|
||||||
|
for _, d := range m.downloads {
|
||||||
|
if d.Infohash.Valid && d.Infohash.String == infohash {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
|
||||||
|
id := int64(len(m.downloads) + 1)
|
||||||
|
cp := *d
|
||||||
|
cp.ID = id
|
||||||
|
m.downloads[id] = &cp
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *memStore) GetDownload(_ context.Context, id int64) (*store.Download, error) {
|
func (m *memStore) GetDownload(_ context.Context, id int64) (*store.Download, error) {
|
||||||
d, ok := m.downloads[id]
|
d, ok := m.downloads[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -143,6 +219,40 @@ func (m *memStore) DeleteFileLinksByBatch(_ context.Context, batch string) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *memStore) CreateCandidates(_ context.Context, cands []store.MetadataCandidate) error {
|
||||||
|
for _, c := range cands {
|
||||||
|
c.ID = int64(len(m.candidates) + 1)
|
||||||
|
m.candidates = append(m.candidates, c)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *memStore) ListCandidatesByRecognition(_ context.Context, recID int64) ([]store.MetadataCandidate, error) {
|
||||||
|
var out []store.MetadataCandidate
|
||||||
|
for _, c := range m.candidates {
|
||||||
|
if c.RecognitionID == recID {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func (m *memStore) GetCandidate(_ context.Context, id int64) (*store.MetadataCandidate, error) {
|
||||||
|
for i := range m.candidates {
|
||||||
|
if m.candidates[i].ID == id {
|
||||||
|
cp := m.candidates[i]
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *memStore) SetCandidateChosen(_ context.Context, recID, id int64) error {
|
||||||
|
for i := range m.candidates {
|
||||||
|
if m.candidates[i].RecognitionID == recID {
|
||||||
|
m.candidates[i].Chosen = m.candidates[i].ID == id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func jsonMarshal(v any) (string, error) {
|
func jsonMarshal(v any) (string, error) {
|
||||||
b, err := json.Marshal(v)
|
b, err := json.Marshal(v)
|
||||||
return string(b), err
|
return string(b), err
|
||||||
@@ -381,7 +491,7 @@ func newApplyFixture(t *testing.T, plan recognize.Plan) applyFixture {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lay, err := layout.New(layout.Config{MoviesDir: movies, SeriesDir: series})
|
lay, err := layout.New(layout.Config{MoviesDir: movies, SeriesDir: series}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -543,7 +653,7 @@ func TestRecognizeOne_AutoApplies(t *testing.T) {
|
|||||||
_ = os.MkdirAll(filepath.Dir(p), 0o755)
|
_ = os.MkdirAll(filepath.Dir(p), 0o755)
|
||||||
_ = os.WriteFile(p, []byte("x"), 0o644)
|
_ = os.WriteFile(p, []byte("x"), 0o644)
|
||||||
}
|
}
|
||||||
lay, _ := layout.New(layout.Config{MoviesDir: movies, SeriesDir: series})
|
lay, _ := layout.New(layout.Config{MoviesDir: movies, SeriesDir: series}, nil)
|
||||||
|
|
||||||
st := newMemStore()
|
st := newMemStore()
|
||||||
st.put(completedDownload(1))
|
st.put(completedDownload(1))
|
||||||
@@ -591,6 +701,7 @@ func TestProviderTag(t *testing.T) {
|
|||||||
cases := []struct{ provider, id, want string }{
|
cases := []struct{ provider, id, want string }{
|
||||||
{"tmdb", "603", "tmdbid-603"},
|
{"tmdb", "603", "tmdbid-603"},
|
||||||
{"tvdb", "123", "tvdbid-123"},
|
{"tvdb", "123", "tvdbid-123"},
|
||||||
|
{"imdb", "tt2802850", "imdbid-tt2802850"},
|
||||||
{"none", "", ""},
|
{"none", "", ""},
|
||||||
{"tmdb", "", ""},
|
{"tmdb", "", ""},
|
||||||
{"weird", "1", ""},
|
{"weird", "1", ""},
|
||||||
@@ -602,6 +713,143 @@ func TestProviderTag(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reviewWithCandidate готовит memStore: задача в review, одна попытка
|
||||||
|
// распознавания с одним кандидатом базы.
|
||||||
|
func reviewWithCandidate(t *testing.T, cand store.MetadataCandidate) (*Worker, *memStore) {
|
||||||
|
t.Helper()
|
||||||
|
st := newMemStore()
|
||||||
|
d := completedDownload(1)
|
||||||
|
d.State = store.StateReview
|
||||||
|
st.put(d)
|
||||||
|
planJSON, _ := json.Marshal(recognize.Plan{Type: recognize.MediaSeries, Title: "Догадка", Year: 2000})
|
||||||
|
st.recs = append(st.recs, &store.Recognition{
|
||||||
|
ID: 1, DownloadID: 1, IsCurrent: true, Plan: store.NullString(string(planJSON)),
|
||||||
|
Provider: store.NullString("none"),
|
||||||
|
})
|
||||||
|
cand.RecognitionID = 1
|
||||||
|
_ = st.CreateCandidates(context.Background(), []store.MetadataCandidate{cand})
|
||||||
|
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
|
||||||
|
return w, st
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecognizeOne_PersistsCandidates(t *testing.T) {
|
||||||
|
st := newMemStore()
|
||||||
|
st.put(completedDownload(1))
|
||||||
|
qb := &fakeQbt{
|
||||||
|
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}},
|
||||||
|
files: []qbt.File{{Name: "e1.mkv", Size: 1}},
|
||||||
|
}
|
||||||
|
res := seriesResult()
|
||||||
|
res.Candidates = []metadata.Candidate{
|
||||||
|
{Provider: "tvmaze", ID: "1", Title: "Show A", Year: 2006, TagProvider: "tvdb", TagID: "269613"},
|
||||||
|
{Provider: "tvmaze", ID: "2", Title: "Show B", Year: 2007},
|
||||||
|
}
|
||||||
|
w := testWorkerWith(st, qb, &fakeRecognizer{result: res}, nil)
|
||||||
|
|
||||||
|
w.recognizeOne(context.Background(), 1)
|
||||||
|
|
||||||
|
if len(st.candidates) != 2 {
|
||||||
|
t.Fatalf("candidates = %d, want 2", len(st.candidates))
|
||||||
|
}
|
||||||
|
// Тег-предпочтительный provider/id сохранён (TVMaze → tvdb).
|
||||||
|
if st.candidates[0].Provider != "tvdb" || st.candidates[0].ProviderID != "269613" {
|
||||||
|
t.Errorf("candidate[0] = %+v", st.candidates[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChooseCandidate_PinsOverrides(t *testing.T) {
|
||||||
|
w, st := reviewWithCandidate(t, store.MetadataCandidate{
|
||||||
|
Provider: "tvdb", ProviderID: "269613",
|
||||||
|
Title: store.NullString("Fargo"), Year: sql.NullInt64{Int64: 2014, Valid: true},
|
||||||
|
})
|
||||||
|
candID := st.candidates[0].ID
|
||||||
|
|
||||||
|
if err := w.ChooseCandidate(context.Background(), 1, candID); err != nil {
|
||||||
|
t.Fatalf("ChooseCandidate: %v", err)
|
||||||
|
}
|
||||||
|
ov := st.overrides[1]
|
||||||
|
if ov[ovrProvider] != "tvdb" || ov[ovrProviderID] != "269613" ||
|
||||||
|
ov[ovrTitle] != "Fargo" || ov[ovrYear] != "2014" {
|
||||||
|
t.Errorf("overrides = %v", ov)
|
||||||
|
}
|
||||||
|
if !st.candidates[0].Chosen {
|
||||||
|
t.Error("кандидат не помечен выбранным")
|
||||||
|
}
|
||||||
|
// Эффективный план берёт каноническое имя/год и тег [tvdbid-...].
|
||||||
|
plan, tag, err := w.effectivePlan(context.Background(), 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("effectivePlan: %v", err)
|
||||||
|
}
|
||||||
|
if plan.Title != "Fargo" || plan.Year != 2014 {
|
||||||
|
t.Errorf("plan = %q (%d)", plan.Title, plan.Year)
|
||||||
|
}
|
||||||
|
if tag != "tvdbid-269613" {
|
||||||
|
t.Errorf("tag = %q", tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChooseCandidate_RejectsForeign(t *testing.T) {
|
||||||
|
w, _ := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
|
||||||
|
if err := w.ChooseCandidate(context.Background(), 1, 999); err == nil {
|
||||||
|
t.Error("чужой кандидат должен отклоняться")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetProviderID(t *testing.T) {
|
||||||
|
w, st := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
|
||||||
|
if err := w.SetProviderID(context.Background(), 1, "TMDB", " 603 "); err != nil {
|
||||||
|
t.Fatalf("SetProviderID: %v", err)
|
||||||
|
}
|
||||||
|
if st.overrides[1][ovrProvider] != "tmdb" || st.overrides[1][ovrProviderID] != "603" {
|
||||||
|
t.Errorf("overrides = %v", st.overrides[1])
|
||||||
|
}
|
||||||
|
if err := w.SetProviderID(context.Background(), 1, "kinopoisk", "1"); err == nil {
|
||||||
|
t.Error("недопустимый провайдер должен отклоняться")
|
||||||
|
}
|
||||||
|
if err := w.SetProviderID(context.Background(), 1, "tmdb", ""); err == nil {
|
||||||
|
t.Error("пустой id должен отклоняться")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClearProvider(t *testing.T) {
|
||||||
|
w, st := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
|
||||||
|
_ = st.SetOverride(context.Background(), 1, ovrProvider, "tvdb")
|
||||||
|
if err := w.ClearProvider(context.Background(), 1); err != nil {
|
||||||
|
t.Fatalf("ClearProvider: %v", err)
|
||||||
|
}
|
||||||
|
if st.overrides[1][ovrProvider] != "none" {
|
||||||
|
t.Errorf("provider override = %q, want none", st.overrides[1][ovrProvider])
|
||||||
|
}
|
||||||
|
// «Без базы» → пустой тег.
|
||||||
|
_, tag, _ := w.effectivePlan(context.Background(), 1)
|
||||||
|
if tag != "" {
|
||||||
|
t.Errorf("tag = %q, want empty", tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReviewData_IncludesCandidates(t *testing.T) {
|
||||||
|
w, st := reviewWithCandidate(t, store.MetadataCandidate{
|
||||||
|
Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
|
||||||
|
})
|
||||||
|
candID := st.candidates[0].ID
|
||||||
|
if err := w.ChooseCandidate(context.Background(), 1, candID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rd, err := w.ReviewData(context.Background(), 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReviewData: %v", err)
|
||||||
|
}
|
||||||
|
if len(rd.Candidates) != 1 {
|
||||||
|
t.Fatalf("candidates = %d", len(rd.Candidates))
|
||||||
|
}
|
||||||
|
if rd.Provider != "tvdb" || rd.ProviderID != "269613" {
|
||||||
|
t.Errorf("eff provider = %s/%s", rd.Provider, rd.ProviderID)
|
||||||
|
}
|
||||||
|
if rd.Plan.Title != "Fargo" {
|
||||||
|
t.Errorf("plan title = %q", rd.Plan.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestToLayoutPlan(t *testing.T) {
|
func TestToLayoutPlan(t *testing.T) {
|
||||||
s, e := 1, 3
|
s, e := 1, 3
|
||||||
plan := recognize.Plan{
|
plan := recognize.Plan{
|
||||||
@@ -625,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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ type Store interface {
|
|||||||
GetDownload(ctx context.Context, id int64) (*store.Download, error)
|
GetDownload(ctx context.Context, id int64) (*store.Download, error)
|
||||||
SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error
|
SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error
|
||||||
|
|
||||||
|
// Discovery (усыновление раздач по категории/тегу).
|
||||||
|
ExistsByInfohash(ctx context.Context, infohash string) (bool, error)
|
||||||
|
CreateDownload(ctx context.Context, d *store.Download) (int64, error)
|
||||||
|
|
||||||
// Ф3: распознавание, ревью, раскладка.
|
// Ф3: распознавание, ревью, раскладка.
|
||||||
CreateRecognition(ctx context.Context, r *store.Recognition, reasons []string) (int64, error)
|
CreateRecognition(ctx context.Context, r *store.Recognition, reasons []string) (int64, error)
|
||||||
GetCurrentRecognition(ctx context.Context, downloadID int64) (*store.Recognition, error)
|
GetCurrentRecognition(ctx context.Context, downloadID int64) (*store.Recognition, error)
|
||||||
@@ -42,6 +46,12 @@ type Store interface {
|
|||||||
LatestBatchID(ctx context.Context, downloadID int64) (string, error)
|
LatestBatchID(ctx context.Context, downloadID int64) (string, error)
|
||||||
ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, error)
|
ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, error)
|
||||||
DeleteFileLinksByBatch(ctx context.Context, batchID string) error
|
DeleteFileLinksByBatch(ctx context.Context, batchID string) error
|
||||||
|
|
||||||
|
// Кандидаты базы метаданных (ручной выбор в review).
|
||||||
|
CreateCandidates(ctx context.Context, cands []store.MetadataCandidate) error
|
||||||
|
ListCandidatesByRecognition(ctx context.Context, recognitionID int64) ([]store.MetadataCandidate, error)
|
||||||
|
GetCandidate(ctx context.Context, id int64) (*store.MetadataCandidate, error)
|
||||||
|
SetCandidateChosen(ctx context.Context, recognitionID, candidateID int64) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// QBittorrent — нужная worker часть клиента qBittorrent.
|
// QBittorrent — нужная worker часть клиента qBittorrent.
|
||||||
@@ -63,10 +73,25 @@ type Layouter interface {
|
|||||||
Undo(ctx context.Context, links []layout.Link) (int, error)
|
Undo(ctx context.Context, links []layout.Link) (int, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NotifyEvent — повод позвать пользователя.
|
||||||
|
type NotifyEvent string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EventReview NotifyEvent = "review" // задача ждёт подтверждения
|
||||||
|
EventDone NotifyEvent = "done" // раскладка завершена
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notifier — исходящие пинги (Telegram). Вызывается неблокирующе.
|
||||||
|
type Notifier interface {
|
||||||
|
Notify(ctx context.Context, downloadID int64, event NotifyEvent)
|
||||||
|
}
|
||||||
|
|
||||||
// Config — параметры воркера.
|
// Config — параметры воркера.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Category string
|
Category string
|
||||||
|
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
|
||||||
@@ -81,11 +106,15 @@ type Worker struct {
|
|||||||
cfg Config
|
cfg Config
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
|
|
||||||
mu sync.Mutex // сериализует переходы (поллинг + команды)
|
mu sync.Mutex // сериализует переходы (поллинг + команды)
|
||||||
now func() time.Time // подменяется в тестах
|
now func() time.Time // подменяется в тестах
|
||||||
newID func() string // генератор apply_batch_id (подменяется в тестах)
|
newID func() string // генератор apply_batch_id (подменяется в тестах)
|
||||||
|
notifier Notifier // опц. исходящие пинги
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetNotifier подключает исходящие пинги (до запуска Run).
|
||||||
|
func (w *Worker) SetNotifier(n Notifier) { w.notifier = n }
|
||||||
|
|
||||||
// New собирает воркер. recognizer/layouter могут быть nil (Ф1 без Ф3-ступеней
|
// New собирает воркер. recognizer/layouter могут быть nil (Ф1 без Ф3-ступеней
|
||||||
// распознавания и раскладки) — тогда completed-задачи не двигаются дальше.
|
// распознавания и раскладки) — тогда completed-задачи не двигаются дальше.
|
||||||
func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log *slog.Logger) *Worker {
|
func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log *slog.Logger) *Worker {
|
||||||
@@ -135,8 +164,10 @@ func (w *Worker) pollOnce(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Poll сверяет активные задачи с состоянием qBittorrent и двигает их.
|
// Poll сверяет активные задачи с состоянием qBittorrent и двигает их.
|
||||||
|
// Листаем все торренты (а не только свою категорию), чтобы reconcile нашёл и
|
||||||
|
// усыновлённые по тегу раздачи, а discovery — увидел новые.
|
||||||
func (w *Worker) Poll(ctx context.Context) error {
|
func (w *Worker) Poll(ctx context.Context) error {
|
||||||
torrents, err := w.qbt.Torrents(ctx, w.cfg.Category)
|
torrents, err := w.qbt.Torrents(ctx, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("poll: list torrents: %w", err)
|
return fmt.Errorf("poll: list torrents: %w", err)
|
||||||
}
|
}
|
||||||
@@ -152,6 +183,9 @@ func (w *Worker) Poll(ctx context.Context) error {
|
|||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
// Усыновляем новые раздачи с нашей категорией/тегом до reconcile.
|
||||||
|
w.discover(ctx, torrents)
|
||||||
|
|
||||||
active, err := w.store.ListDownloadsByState(ctx, store.StateDownloading)
|
active, err := w.store.ListDownloadsByState(ctx, store.StateDownloading)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("poll: list active: %w", err)
|
return fmt.Errorf("poll: list active: %w", err)
|
||||||
@@ -215,6 +249,17 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
|
|||||||
}
|
}
|
||||||
w.log.Info("state transition",
|
w.log.Info("state transition",
|
||||||
"download_id", d.ID, "from", d.State, "to", state, "code", code)
|
"download_id", d.ID, "from", d.State, "to", state, "code", code)
|
||||||
|
|
||||||
|
// Пинги — неблокирующе и в отдельном контексте: вызов уходит в сеть, а
|
||||||
|
// мы под w.mu (Notify читает состояние уже после освобождения замка).
|
||||||
|
if w.notifier != nil {
|
||||||
|
switch state {
|
||||||
|
case store.StateReview:
|
||||||
|
go w.notifier.Notify(context.Background(), d.ID, EventReview)
|
||||||
|
case store.StateDone:
|
||||||
|
go w.notifier.Notify(context.Background(), d.ID, EventDone)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает
|
// Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает
|
||||||
|
|||||||
@@ -51,6 +51,23 @@ func (f *fakeStore) GetDownload(_ context.Context, id int64) (*store.Download, e
|
|||||||
return &cp, nil
|
return &cp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ExistsByInfohash(_ context.Context, infohash string) (bool, error) {
|
||||||
|
for _, d := range f.downloads {
|
||||||
|
if d.Infohash.Valid && d.Infohash.String == infohash {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
|
||||||
|
id := int64(len(f.downloads) + 1)
|
||||||
|
cp := *d
|
||||||
|
cp.ID = id
|
||||||
|
f.downloads[id] = &cp
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State, code, msg string) error {
|
func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State, code, msg string) error {
|
||||||
d, ok := f.downloads[id]
|
d, ok := f.downloads[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -83,6 +100,16 @@ func (f *fakeStore) ListFileLinksByBatch(_ context.Context, _ string) ([]store.F
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (f *fakeStore) DeleteFileLinksByBatch(_ context.Context, _ string) error { return nil }
|
func (f *fakeStore) DeleteFileLinksByBatch(_ context.Context, _ string) error { return nil }
|
||||||
|
func (f *fakeStore) CreateCandidates(_ context.Context, _ []store.MetadataCandidate) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) ListCandidatesByRecognition(_ context.Context, _ int64) ([]store.MetadataCandidate, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) GetCandidate(_ context.Context, _ int64) (*store.MetadataCandidate, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ int64) error { return nil }
|
||||||
|
|
||||||
type fakeQbt struct {
|
type fakeQbt struct {
|
||||||
torrents []qbt.Torrent
|
torrents []qbt.Torrent
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
Тип: <b>{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}</b>
|
Тип: <b>{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}</b>
|
||||||
· Название: <b>{{.Title}}</b>{{if .OriginalTitle}} <small>({{.OriginalTitle}})</small>{{end}}
|
· Название: <b>{{.Title}}</b>{{if .OriginalTitle}} <small>({{.OriginalTitle}})</small>{{end}}
|
||||||
{{if .Year}}· Год: <b>{{.Year}}</b>{{end}}
|
{{if .Year}}· Год: <b>{{.Year}}</b>{{end}}
|
||||||
{{if .Provider}}· База: <b>{{.Provider}}</b> {{.ProviderID}}{{end}}
|
{{if .Provider}}· База: <b>{{.Provider}}</b> {{.ProviderID}}{{else if .NoBase}}· База: <b>без базы</b>{{end}}
|
||||||
</p>
|
</p>
|
||||||
<form class="row" method="post" action="/ui/downloads/{{.ID}}/type">
|
<form class="row" method="post" action="/ui/downloads/{{.ID}}/type">
|
||||||
Переключить тип:
|
Переключить тип:
|
||||||
@@ -67,6 +67,49 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<strong>База метаданных</strong>
|
||||||
|
{{if .Provider}}<p>Выбрано: <b>{{.Provider}}</b> {{.ProviderID}}</p>
|
||||||
|
{{else if .NoBase}}<p>Выбрано: <b>без базы</b> (тег папки не ставится)</p>
|
||||||
|
{{else}}<p><small>Матч не подтверждён — выберите кандидата, введите id или «без базы».</small></p>{{end}}
|
||||||
|
|
||||||
|
{{if .Candidates}}
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>провайдер</th><th>название</th><th>год</th><th>id</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Candidates}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Provider}}</td>
|
||||||
|
<td>{{.Title}}</td>
|
||||||
|
<td>{{if .Year}}{{.Year}}{{end}}</td>
|
||||||
|
<td class="src">{{.ProviderID}}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/ui/downloads/{{$.ID}}/candidate">
|
||||||
|
<input type="hidden" name="candidate_id" value="{{.ID}}">
|
||||||
|
<button type="submit" {{if .Chosen}}disabled{{end}}>{{if .Chosen}}выбрано{{else}}выбрать{{end}}</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form class="row" method="post" action="/ui/downloads/{{.ID}}/provider">
|
||||||
|
Вручную:
|
||||||
|
<select name="provider">
|
||||||
|
<option value="tvdb">tvdb</option>
|
||||||
|
<option value="tmdb">tmdb</option>
|
||||||
|
<option value="imdb">imdb</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" name="provider_id" placeholder="id (напр. 269613)" required>
|
||||||
|
<button type="submit">задать id</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/ui/downloads/{{.ID}}/nobase" style="margin-top:.4rem">
|
||||||
|
<button type="submit">Без базы</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<strong>Файлы → роль</strong>
|
<strong>Файлы → роль</strong>
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
Reference in New Issue
Block a user