Compare commits
2
Commits
783664622c
...
dfa182a5a9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfa182a5a9
|
||
|
|
6b7c090ce4
|
@@ -80,6 +80,7 @@ erDiagram
|
|||||||
TEXT provider_id "NOT NULL"
|
TEXT provider_id "NOT NULL"
|
||||||
TEXT title "nullable"
|
TEXT title "nullable"
|
||||||
INTEGER year "nullable"
|
INTEGER year "nullable"
|
||||||
|
TEXT url "nullable; ссылка на страницу на сайте провайдера"
|
||||||
INTEGER chosen "NOT NULL DEFAULT 0; 0/1"
|
INTEGER chosen "NOT NULL DEFAULT 0; 0/1"
|
||||||
TEXT created_at "NOT NULL DEFAULT datetime('now')"
|
TEXT created_at "NOT NULL DEFAULT datetime('now')"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,19 @@ inode общий — диск не дублируется.
|
|||||||
же inode → готово; другой файл → коллизия → review). Инварианты и undo —
|
же inode → готово; другой файл → коллизия → review). Инварианты и undo —
|
||||||
в [architecture.md](architecture.md) → «Раскладка файлов».
|
в [architecture.md](architecture.md) → «Раскладка файлов».
|
||||||
|
|
||||||
|
## Владение целевым путём
|
||||||
|
|
||||||
|
Целевой путь принадлежит **одной** загрузке. Когда новая раскладка
|
||||||
|
успешно ложится на путь, который раньше занимала другая загрузка (путь к
|
||||||
|
этому моменту **свободен** — иначе была бы коллизия → review, чужой файл
|
||||||
|
не перезаписываем), владение переходит к новой загрузке: прежние записи
|
||||||
|
`file_link` на этот путь помечаются статусом `superseded` и перестают
|
||||||
|
считаться целью прежней загрузки. Это нужно сверке с реальностью: иначе
|
||||||
|
повторная закачка того же фильма (например, в другом качестве) по тому же
|
||||||
|
пути ложно «воскрешала» бы удалённую задачу — см.
|
||||||
|
[workflow.md](workflow.md) → «Сверка с реальностью». `superseded`-ссылки
|
||||||
|
не считаются целью при сверке и не снимаются в `Undo`.
|
||||||
|
|
||||||
Желательно: целевой и исходный каталоги — на одной ФС/одном mount'е
|
Желательно: целевой и исходный каталоги — на одной ФС/одном mount'е
|
||||||
(внутри контейнера это обеспечивает единая песочница `/srv/media`), тогда
|
(внутри контейнера это обеспечивает единая песочница `/srv/media`), тогда
|
||||||
работает дешёвый хардлинк. Если хардлинк невозможен (разные ФС или ФС без
|
работает дешёвый хардлинк. Если хардлинк невозможен (разные ФС или ФС без
|
||||||
|
|||||||
+12
-5
@@ -107,12 +107,19 @@ stateDiagram-v2
|
|||||||
«Привязать заново» (`→ recognizing`); авто-действий нет.
|
«Привязать заново» (`→ recognizing`); авто-действий нет.
|
||||||
- **orphaned** — источник пропал, цель (последняя копия данных) на месте.
|
- **orphaned** — источник пропал, цель (последняя копия данных) на месте.
|
||||||
Команд вперёд нет; `Undo` запрещён (снял бы единственную копию).
|
Команд вперёд нет; `Undo` запрещён (снял бы единственную копию).
|
||||||
- **deleted** — нет ни источника, ни цели; терминально.
|
- **deleted** — нет ни источника, ни цели; **терминально**: сверка его
|
||||||
|
больше не переоценивает (см. ниже).
|
||||||
|
|
||||||
Сверка трогает только `done`/`target_missing`/`orphaned`/`deleted` —
|
Сверка трогает только `done`/`target_missing`/`orphaned` — терминальный
|
||||||
активные и пользовательски-терминальные (`reverted`/`cancelled`/`failed`/
|
`deleted`, активные и пользовательски-терминальные (`reverted`/`cancelled`/
|
||||||
`stuck`) состояния не задевает. Реальность «лечится» сама: при возврате
|
`failed`/`stuck`) состояния не задевает. Реальность «лечится» сама: при
|
||||||
источника/цели задача переходит обратно (вплоть до `done`). Пропажа
|
возврате источника/цели задача переходит обратно (вплоть до `done`) — но
|
||||||
|
**не из `deleted`**: к терминальной задаче источник не вернётся
|
||||||
|
(идемпотентность снимается только для активных), а её бывший целевой путь, если
|
||||||
|
его заняла другая загрузка, отбирается переходом владения (см.
|
||||||
|
[jellyfin-layout.md](jellyfin-layout.md) → «Владение целевым путём»).
|
||||||
|
Без этого правила переиспользование пути ложно «воскрешало» бы удалённую
|
||||||
|
задачу в `orphaned`. Пропажа
|
||||||
**источника** дебаунсится (`[worker].source_missing_threshold` подряд идущих
|
**источника** дебаунсится (`[worker].source_missing_threshold` подряд идущих
|
||||||
тиков), пропажа цели проверяется немедленно (локальная ФС надёжна). Команды,
|
тиков), пропажа цели проверяется немедленно (локальная ФС надёжна). Команды,
|
||||||
которым нужен источник (relink/распознать/применить/undo), проверяют его
|
которым нужен источник (relink/распознать/применить/undo), проверяют его
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ type candidateView struct {
|
|||||||
ProviderID string
|
ProviderID string
|
||||||
Title string
|
Title string
|
||||||
Year int
|
Year int
|
||||||
|
URL string
|
||||||
Chosen bool
|
Chosen bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +131,7 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
ProviderID: c.ProviderID,
|
ProviderID: c.ProviderID,
|
||||||
Title: c.Title.String,
|
Title: c.Title.String,
|
||||||
Year: int(c.Year.Int64),
|
Year: int(c.Year.Int64),
|
||||||
|
URL: c.URL.String,
|
||||||
Chosen: c.Chosen,
|
Chosen: c.Chosen,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,10 +213,11 @@ 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" // хардлинк невозможен — файл скопирован (фолбэк)
|
StatusCopied LinkStatus = "copied" // хардлинк невозможен — файл скопирован (фолбэк)
|
||||||
StatusExists LinkStatus = "exists" // уже была (тот же inode) — идемпотентно
|
StatusExists LinkStatus = "exists" // уже была (тот же inode) — идемпотентно
|
||||||
StatusCollision LinkStatus = "collision" // цель занята другим файлом
|
StatusCollision LinkStatus = "collision" // цель занята другим файлом
|
||||||
|
StatusSuperseded LinkStatus = "superseded" // путь перехватила другая загрузка (см. state-reconciliation, владение путём)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Result — итог по одной ссылке.
|
// Result — итог по одной ссылке.
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ type Candidate struct {
|
|||||||
Title string
|
Title string
|
||||||
OriginalTitle string
|
OriginalTitle string
|
||||||
Year int
|
Year int
|
||||||
|
URL string // ссылка на страницу элемента на сайте провайдера
|
||||||
TagProvider string // напр. "tvdb"/"imdb" (опц.)
|
TagProvider string // напр. "tvdb"/"imdb" (опц.)
|
||||||
TagID string
|
TagID string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ func (t *TMDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
|||||||
return nil, fmt.Errorf("tmdb search: %w", err)
|
return nil, fmt.Errorf("tmdb search: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
webPath := "movie"
|
||||||
|
if q.Type == Series {
|
||||||
|
webPath = "tv"
|
||||||
|
}
|
||||||
out := make([]Candidate, 0, len(resp.Results))
|
out := make([]Candidate, 0, len(resp.Results))
|
||||||
for _, r := range resp.Results {
|
for _, r := range resp.Results {
|
||||||
title, orig, date := r.Title, r.OriginalTitle, r.ReleaseDate
|
title, orig, date := r.Title, r.OriginalTitle, r.ReleaseDate
|
||||||
@@ -110,6 +114,7 @@ func (t *TMDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
|||||||
Title: title,
|
Title: title,
|
||||||
OriginalTitle: orig,
|
OriginalTitle: orig,
|
||||||
Year: yearOf(date),
|
Year: yearOf(date),
|
||||||
|
URL: "https://www.themoviedb.org/" + webPath + "/" + strconv.Itoa(r.ID),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ func TestTMDB_SearchMovie(t *testing.T) {
|
|||||||
if c.Provider != "tmdb" || c.ID != "603" || c.Title != "The Matrix" || c.Year != 1999 {
|
if c.Provider != "tmdb" || c.ID != "603" || c.Title != "The Matrix" || c.Year != 1999 {
|
||||||
t.Errorf("candidate = %+v", c)
|
t.Errorf("candidate = %+v", c)
|
||||||
}
|
}
|
||||||
|
if c.URL != "https://www.themoviedb.org/movie/603" {
|
||||||
|
t.Errorf("URL = %q", c.URL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTMDB_SearchSeries(t *testing.T) {
|
func TestTMDB_SearchSeries(t *testing.T) {
|
||||||
@@ -65,6 +68,9 @@ func TestTMDB_SearchSeries(t *testing.T) {
|
|||||||
if len(got) != 1 || got[0].ID != "60622" || got[0].Title != "Fargo" || got[0].Year != 2014 {
|
if len(got) != 1 || got[0].ID != "60622" || got[0].Title != "Fargo" || got[0].Year != 2014 {
|
||||||
t.Errorf("candidate = %+v", got[0])
|
t.Errorf("candidate = %+v", got[0])
|
||||||
}
|
}
|
||||||
|
if got[0].URL != "https://www.themoviedb.org/tv/60622" {
|
||||||
|
t.Errorf("URL = %q", got[0].URL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTMDB_SeasonEpisodeCounts(t *testing.T) {
|
func TestTMDB_SeasonEpisodeCounts(t *testing.T) {
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ func (t *TVDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
|||||||
ID: r.TVDBID,
|
ID: r.TVDBID,
|
||||||
Title: r.Name,
|
Title: r.Name,
|
||||||
Year: year,
|
Year: year,
|
||||||
|
URL: "https://www.thetvdb.com/dereferrer/series/" + r.TVDBID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ func TestTVDB_SearchAndLoginCached(t *testing.T) {
|
|||||||
if len(got) != 1 || got[0].ID != "269613" || got[0].Provider != "tvdb" || got[0].Year != 2014 {
|
if len(got) != 1 || got[0].ID != "269613" || got[0].Provider != "tvdb" || got[0].Year != 2014 {
|
||||||
t.Fatalf("candidate = %+v", got)
|
t.Fatalf("candidate = %+v", got)
|
||||||
}
|
}
|
||||||
|
if got[0].URL != "https://www.thetvdb.com/dereferrer/series/269613" {
|
||||||
|
t.Fatalf("URL = %q", got[0].URL)
|
||||||
|
}
|
||||||
// Второй запрос переиспользует токен — повторного логина нет.
|
// Второй запрос переиспользует токен — повторного логина нет.
|
||||||
if _, err := c.Search(context.Background(), Query{Type: Series, Title: "Fargo"}); err != nil {
|
if _, err := c.Search(context.Background(), Query{Type: Series, Title: "Fargo"}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ func (t *TVMaze) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
|||||||
ID: strconv.Itoa(s.ID),
|
ID: strconv.Itoa(s.ID),
|
||||||
Title: s.Name,
|
Title: s.Name,
|
||||||
Year: yearOf(s.Premiered),
|
Year: yearOf(s.Premiered),
|
||||||
|
URL: "https://www.tvmaze.com/shows/" + strconv.Itoa(s.ID),
|
||||||
}
|
}
|
||||||
// Тег папки — привычный TVDB-id, если есть; иначе IMDb.
|
// Тег папки — привычный TVDB-id, если есть; иначе IMDb.
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ func TestTVMaze_SearchSeries(t *testing.T) {
|
|||||||
if c.Provider != "tvmaze" || c.ID != "1" || c.Title != "Fargo" || c.Year != 2014 {
|
if c.Provider != "tvmaze" || c.ID != "1" || c.Title != "Fargo" || c.Year != 2014 {
|
||||||
t.Errorf("candidate = %+v", c)
|
t.Errorf("candidate = %+v", c)
|
||||||
}
|
}
|
||||||
|
if c.URL != "https://www.tvmaze.com/shows/1" {
|
||||||
|
t.Errorf("URL = %q", c.URL)
|
||||||
|
}
|
||||||
// TVDB-id из externals → тег папки.
|
// TVDB-id из externals → тег папки.
|
||||||
if c.TagProvider != "tvdb" || c.TagID != "269613" {
|
if c.TagProvider != "tvdb" || c.TagID != "269613" {
|
||||||
t.Errorf("tag = %s/%s, want tvdb/269613", c.TagProvider, c.TagID)
|
t.Errorf("tag = %s/%s, want tvdb/269613", c.TagProvider, c.TagID)
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE metadata_candidate ADD COLUMN url TEXT;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- SQLite не умеет DROP COLUMN в старых версиях, но modernc.org/sqlite
|
||||||
|
-- поддерживает ALTER TABLE DROP COLUMN начиная с 3.35.0.
|
||||||
|
ALTER TABLE metadata_candidate DROP COLUMN url;
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Recognition — строка таблицы recognition (попытка распознавания).
|
// Recognition — строка таблицы recognition (попытка распознавания).
|
||||||
@@ -195,6 +196,34 @@ VALUES (?, ?, ?, ?, ?, ?)`
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SupersedeForeignLinks помечает статусом superseded ссылки ДРУГИХ загрузок,
|
||||||
|
// разложенные по тем же dst_path, что и переданные. Реализует инвариант
|
||||||
|
// «один целевой путь — один владелец» (см. state-reconciliation): свежая
|
||||||
|
// раскладка забирает владение освободившимся путём, прежние записи перестают
|
||||||
|
// считаться целью при сверке. Затрагивает только активные статусы раскладки
|
||||||
|
// (linked/copied/exists) и не трогает саму загрузку (download_id != ?).
|
||||||
|
func (s *Store) SupersedeForeignLinks(ctx context.Context, downloadID int64, dstPaths []string) error {
|
||||||
|
if len(dstPaths) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ph := make([]string, len(dstPaths))
|
||||||
|
args := make([]any, 0, len(dstPaths)+1)
|
||||||
|
args = append(args, downloadID)
|
||||||
|
for i, p := range dstPaths {
|
||||||
|
ph[i] = "?"
|
||||||
|
args = append(args, p)
|
||||||
|
}
|
||||||
|
q := fmt.Sprintf(`
|
||||||
|
UPDATE file_link SET status = 'superseded'
|
||||||
|
WHERE download_id != ?
|
||||||
|
AND status IN ('linked', 'copied', 'exists')
|
||||||
|
AND dst_path IN (%s)`, strings.Join(ph, ", "))
|
||||||
|
if _, err := s.DB.ExecContext(ctx, q, args...); err != nil {
|
||||||
|
return fmt.Errorf("supersede foreign links: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// LatestBatchID возвращает apply_batch_id последнего применённого батча
|
// LatestBatchID возвращает apply_batch_id последнего применённого батча
|
||||||
// загрузки (для undo) либо пустую строку, если ссылок нет.
|
// загрузки (для undo) либо пустую строку, если ссылок нет.
|
||||||
func (s *Store) LatestBatchID(ctx context.Context, downloadID int64) (string, error) {
|
func (s *Store) LatestBatchID(ctx context.Context, downloadID int64) (string, error) {
|
||||||
@@ -242,6 +271,7 @@ type MetadataCandidate struct {
|
|||||||
ProviderID string `db:"provider_id"`
|
ProviderID string `db:"provider_id"`
|
||||||
Title sql.NullString `db:"title"`
|
Title sql.NullString `db:"title"`
|
||||||
Year sql.NullInt64 `db:"year"`
|
Year sql.NullInt64 `db:"year"`
|
||||||
|
URL sql.NullString `db:"url"`
|
||||||
Chosen bool `db:"chosen"`
|
Chosen bool `db:"chosen"`
|
||||||
CreatedAt string `db:"created_at"`
|
CreatedAt string `db:"created_at"`
|
||||||
}
|
}
|
||||||
@@ -258,11 +288,11 @@ func (s *Store) CreateCandidates(ctx context.Context, cands []MetadataCandidate)
|
|||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
const q = `
|
const q = `
|
||||||
INSERT INTO metadata_candidate (recognition_id, provider, provider_id, title, year)
|
INSERT INTO metadata_candidate (recognition_id, provider, provider_id, title, year, url)
|
||||||
VALUES (?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?)`
|
||||||
for _, c := range cands {
|
for _, c := range cands {
|
||||||
if _, err := tx.ExecContext(ctx, q,
|
if _, err := tx.ExecContext(ctx, q,
|
||||||
c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year); err != nil {
|
c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year, c.URL); err != nil {
|
||||||
return fmt.Errorf("insert candidate: %w", err)
|
return fmt.Errorf("insert candidate: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,6 +159,56 @@ func TestFileLinks_BatchLifecycle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSupersedeForeignLinks(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
owner := seedDownload(t, st)
|
||||||
|
foreign, err := st.CreateDownload(ctx,
|
||||||
|
newDownloading("bbccddeeff00112233445566778899aabbccddee"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed foreign: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
shared := "/m/Movie (2024).mkv"
|
||||||
|
// foreign разложена по shared (linked) и по своему пути (exists);
|
||||||
|
// owner разложен по shared и по третьему пути.
|
||||||
|
if err := st.CreateFileLinks(ctx, []FileLink{
|
||||||
|
{DownloadID: foreign, ApplyBatchID: "f", SrcPath: "/d/f.mkv", DstPath: shared, Kind: "video", Status: "linked"},
|
||||||
|
{DownloadID: foreign, ApplyBatchID: "f", SrcPath: "/d/g.mkv", DstPath: "/m/Other (2024).mkv", Kind: "video", Status: "exists"},
|
||||||
|
{DownloadID: owner, ApplyBatchID: "o", SrcPath: "/d/o.mkv", DstPath: shared, Kind: "video", Status: "linked"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create links: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := st.SupersedeForeignLinks(ctx, owner, []string{shared}); err != nil {
|
||||||
|
t.Fatalf("supersede: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
links, _ := st.ListFileLinksByBatch(ctx, "f")
|
||||||
|
for _, l := range links {
|
||||||
|
switch l.DstPath {
|
||||||
|
case shared:
|
||||||
|
if l.Status != "superseded" {
|
||||||
|
t.Errorf("чужая ссылка на %q = %q, want superseded", shared, l.Status)
|
||||||
|
}
|
||||||
|
default: // /m/Other — другой путь, не трогаем
|
||||||
|
if l.Status != "exists" {
|
||||||
|
t.Errorf("ссылка на %q = %q, want exists (не тронута)", l.DstPath, l.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Свою ссылку owner не суперсидит (download_id != self).
|
||||||
|
own, _ := st.ListFileLinksByBatch(ctx, "o")
|
||||||
|
if len(own) != 1 || own[0].Status != "linked" {
|
||||||
|
t.Errorf("своя ссылка = %+v, want linked", own)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустой список путей — no-op, без ошибки.
|
||||||
|
if err := st.SupersedeForeignLinks(ctx, owner, nil); err != nil {
|
||||||
|
t.Errorf("пустой dstPaths должен быть no-op: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCandidates_Lifecycle(t *testing.T) {
|
func TestCandidates_Lifecycle(t *testing.T) {
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -14,14 +14,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// desyncStates — состояния, которые ведёт сверка с реальностью: уже
|
// desyncStates — состояния, которые ведёт сверка с реальностью: уже
|
||||||
// разложенные (done) и сами состояния рассинхрона. Активные и
|
// разложенные (done) и восстановимые состояния рассинхрона. deleted сюда не
|
||||||
|
// входит — оно терминально и сверкой не переоценивается (источник к
|
||||||
|
// терминальной задаче не вернётся из-за идемпотентности, а цель отбирается
|
||||||
|
// переходом владения путём; см. state-reconciliation). Активные и
|
||||||
// пользовательски-терминальные (reverted/cancelled/failed/stuck) сверка не
|
// пользовательски-терминальные (reverted/cancelled/failed/stuck) сверка не
|
||||||
// трогает (см. state-reconciliation).
|
// трогает.
|
||||||
var desyncStates = []store.State{
|
var desyncStates = []store.State{
|
||||||
store.StateDone,
|
store.StateDone,
|
||||||
store.StateTargetMissing,
|
store.StateTargetMissing,
|
||||||
store.StateOrphaned,
|
store.StateOrphaned,
|
||||||
store.StateDeleted,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// deriveState выводит состояние задачи из двумерной матрицы «источник × цель»
|
// deriveState выводит состояние задачи из двумерной матрицы «источник × цель»
|
||||||
|
|||||||
@@ -99,6 +99,18 @@ func TestReconcilePartialTargetLoss(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReconcileSkipsDeleted(t *testing.T) {
|
||||||
|
// deleted терминально: сверка его не переоценивает, даже если по бывшему
|
||||||
|
// пути снова появился файл (его забрала другая загрузка) и источника нет.
|
||||||
|
f := newReconcileFixture(t, store.StateDeleted, false, true)
|
||||||
|
if err := f.w.Poll(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Poll: %v", err)
|
||||||
|
}
|
||||||
|
if got := f.st.downloads[1].State; got != store.StateDeleted {
|
||||||
|
t.Errorf("state = %q, want deleted (сверка не трогает терминальное)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReconcileDebounce(t *testing.T) {
|
func TestReconcileDebounce(t *testing.T) {
|
||||||
// Порог 3: первые два промаха не помечают, третий — помечает orphaned;
|
// Порог 3: первые два промаха не помечают, третий — помечает orphaned;
|
||||||
// возврат источника лечит обратно и сбрасывает счётчик.
|
// возврат источника лечит обратно и сбрасывает счётчик.
|
||||||
|
|||||||
@@ -280,6 +280,22 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
|||||||
if err := w.store.CreateFileLinks(ctx, fl); err != nil {
|
if err := w.store.CreateFileLinks(ctx, fl); err != nil {
|
||||||
return fmt.Errorf("persist links: %w", err)
|
return fmt.Errorf("persist links: %w", err)
|
||||||
}
|
}
|
||||||
|
// Инвариант «один целевой путь — один владелец»: забираем владение
|
||||||
|
// фактически разложенными путями у прежних загрузок (см.
|
||||||
|
// state-reconciliation). Сверка тех загрузок перестанет считать эти
|
||||||
|
// пути своей целью и не «воскресит» их. Это учётная операция, не
|
||||||
|
// безопасность данных: файлы уже разложены, поэтому её сбой не
|
||||||
|
// стрэндит задачу в linking — логируем WARN и доводим до done, а
|
||||||
|
// рассинхрон чужих задач исправит следующий тик сверки.
|
||||||
|
owned := make([]string, 0, len(fl))
|
||||||
|
for _, l := range fl {
|
||||||
|
if isLaidOut(l.Status) {
|
||||||
|
owned = append(owned, l.DstPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := w.store.SupersedeForeignLinks(ctx, d.ID, owned); err != nil {
|
||||||
|
logctx.From(ctx).Warn("supersede foreign links failed", "error", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if applyErr != nil {
|
if applyErr != nil {
|
||||||
@@ -494,9 +510,15 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("undo: %w", err)
|
return fmt.Errorf("undo: %w", err)
|
||||||
}
|
}
|
||||||
links := make([]layout.Link, len(rows))
|
// Снимаем только реально разложенные нами ссылки. superseded-строки —
|
||||||
for i, r := range rows {
|
// путь забрала другая загрузка (см. state-reconciliation, владение
|
||||||
links[i] = layout.Link{Src: r.SrcPath, Dst: r.DstPath, Kind: layout.Kind(r.Kind)}
|
// путём); файл по нему теперь её хардлинк, трогать его нельзя.
|
||||||
|
links := make([]layout.Link, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
if !isLaidOut(r.Status) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
links = append(links, layout.Link{Src: r.SrcPath, Dst: r.DstPath, Kind: layout.Kind(r.Kind)})
|
||||||
}
|
}
|
||||||
n, err := w.layouter.Undo(ctx, links)
|
n, err := w.layouter.Undo(ctx, links)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -763,6 +785,9 @@ func toStoreCandidates(recognitionID int64, cands []metadata.Candidate) []store.
|
|||||||
if c.Year != 0 {
|
if c.Year != 0 {
|
||||||
mc.Year = sql.NullInt64{Int64: int64(c.Year), Valid: true}
|
mc.Year = sql.NullInt64{Int64: int64(c.Year), Valid: true}
|
||||||
}
|
}
|
||||||
|
if c.URL != "" {
|
||||||
|
mc.URL = store.NullString(c.URL)
|
||||||
|
}
|
||||||
out = append(out, mc)
|
out = append(out, mc)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -353,6 +353,25 @@ func (m *memStore) CreateFileLinks(_ context.Context, links []store.FileLink) er
|
|||||||
m.links = append(m.links, links...)
|
m.links = append(m.links, links...)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func (m *memStore) SupersedeForeignLinks(_ context.Context, downloadID int64, dstPaths []string) error {
|
||||||
|
if len(dstPaths) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
want := make(map[string]bool, len(dstPaths))
|
||||||
|
for _, p := range dstPaths {
|
||||||
|
want[p] = true
|
||||||
|
}
|
||||||
|
for i := range m.links {
|
||||||
|
l := &m.links[i]
|
||||||
|
if l.DownloadID == downloadID || !want[l.DstPath] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isLaidOut(l.Status) {
|
||||||
|
l.Status = string(layout.StatusSuperseded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func (m *memStore) LatestBatchID(_ context.Context, id int64) (string, error) {
|
func (m *memStore) LatestBatchID(_ context.Context, id int64) (string, error) {
|
||||||
for i := len(m.links) - 1; i >= 0; i-- {
|
for i := len(m.links) - 1; i >= 0; i-- {
|
||||||
if m.links[i].DownloadID == id {
|
if m.links[i].DownloadID == id {
|
||||||
@@ -769,6 +788,72 @@ func TestApply_CollisionStaysReview(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApply_SupersedesForeignOwnerOfPath(t *testing.T) {
|
||||||
|
// Прежняя загрузка (id=2) была разложена по тому же пути, её файл удалён.
|
||||||
|
// Новая раскладка (id=1) ложится на освободившийся путь и забирает владение.
|
||||||
|
plan := seriesResult().Plan
|
||||||
|
f := newApplyFixture(t, plan)
|
||||||
|
e01 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
|
||||||
|
f.st.put(completedDownload(2))
|
||||||
|
f.st.links = append(f.st.links, store.FileLink{
|
||||||
|
DownloadID: 2, ApplyBatchID: "old", SrcPath: "/old/e1.mkv", DstPath: e01,
|
||||||
|
Kind: "video", Status: "linked",
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := f.w.Apply(context.Background(), 1); err != nil {
|
||||||
|
t.Fatalf("Apply: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Чужая ссылка на перехваченный путь — superseded.
|
||||||
|
var foreign *store.FileLink
|
||||||
|
for i := range f.st.links {
|
||||||
|
if f.st.links[i].DownloadID == 2 {
|
||||||
|
foreign = &f.st.links[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if foreign == nil || foreign.Status != string(layout.StatusSuperseded) {
|
||||||
|
t.Errorf("чужая ссылка status = %v, want superseded", foreign)
|
||||||
|
}
|
||||||
|
// Свои ссылки (id=1) не тронуты — download_id != self.
|
||||||
|
for _, l := range f.st.links {
|
||||||
|
if l.DownloadID == 1 && !isLaidOut(l.Status) {
|
||||||
|
t.Errorf("своя ссылка %q стала %q, ожидали разложенную", l.DstPath, l.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Прежняя загрузка больше не владеет путём → цель отсутствует.
|
||||||
|
present, err := f.w.targetPresent(context.Background(), 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("targetPresent: %v", err)
|
||||||
|
}
|
||||||
|
if present {
|
||||||
|
t.Error("targetPresent(2) = true, want false (путь забран)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_CollisionKeepsForeignOwner(t *testing.T) {
|
||||||
|
// Если файл прежней загрузки ВСЁ ЕЩЁ на месте — коллизия → review, владение
|
||||||
|
// не отбирается (supersede не срабатывает для не-разложенного пути).
|
||||||
|
plan := seriesResult().Plan
|
||||||
|
f := newApplyFixture(t, plan)
|
||||||
|
e01 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
|
||||||
|
_ = os.MkdirAll(filepath.Dir(e01), 0o755)
|
||||||
|
_ = os.WriteFile(e01, []byte("foreign"), 0o644)
|
||||||
|
f.st.put(completedDownload(2))
|
||||||
|
f.st.links = append(f.st.links, store.FileLink{
|
||||||
|
DownloadID: 2, ApplyBatchID: "old", SrcPath: "/old/e1.mkv", DstPath: e01,
|
||||||
|
Kind: "video", Status: "linked",
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := f.w.Apply(context.Background(), 1); err == nil {
|
||||||
|
t.Fatal("want collision error")
|
||||||
|
}
|
||||||
|
for _, l := range f.st.links {
|
||||||
|
if l.DownloadID == 2 && l.Status != "linked" {
|
||||||
|
t.Errorf("чужая ссылка стала %q при коллизии, владение не должно отбираться", l.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUndo_RevertsLinks(t *testing.T) {
|
func TestUndo_RevertsLinks(t *testing.T) {
|
||||||
plan := seriesResult().Plan
|
plan := seriesResult().Plan
|
||||||
f := newApplyFixture(t, plan)
|
f := newApplyFixture(t, plan)
|
||||||
@@ -941,7 +1026,7 @@ func TestRecognizeOne_PersistsCandidates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
res := seriesResult()
|
res := seriesResult()
|
||||||
res.Candidates = []metadata.Candidate{
|
res.Candidates = []metadata.Candidate{
|
||||||
{Provider: "tvmaze", ID: "1", Title: "Show A", Year: 2006, TagProvider: "tvdb", TagID: "269613"},
|
{Provider: "tvmaze", ID: "1", Title: "Show A", Year: 2006, TagProvider: "tvdb", TagID: "269613", URL: "https://www.tvmaze.com/shows/1"},
|
||||||
{Provider: "tvmaze", ID: "2", Title: "Show B", Year: 2007},
|
{Provider: "tvmaze", ID: "2", Title: "Show B", Year: 2007},
|
||||||
}
|
}
|
||||||
w := testWorkerWith(st, qb, &fakeRecognizer{result: res}, nil)
|
w := testWorkerWith(st, qb, &fakeRecognizer{result: res}, nil)
|
||||||
@@ -955,6 +1040,14 @@ func TestRecognizeOne_PersistsCandidates(t *testing.T) {
|
|||||||
if st.candidates[0].Provider != "tvdb" || st.candidates[0].ProviderID != "269613" {
|
if st.candidates[0].Provider != "tvdb" || st.candidates[0].ProviderID != "269613" {
|
||||||
t.Errorf("candidate[0] = %+v", st.candidates[0])
|
t.Errorf("candidate[0] = %+v", st.candidates[0])
|
||||||
}
|
}
|
||||||
|
// URL первого кандидата сохранён.
|
||||||
|
if st.candidates[0].URL.String != "https://www.tvmaze.com/shows/1" || !st.candidates[0].URL.Valid {
|
||||||
|
t.Errorf("candidate[0].URL = (%q, valid=%v), want url", st.candidates[0].URL.String, st.candidates[0].URL.Valid)
|
||||||
|
}
|
||||||
|
// У второго кандидата URL не задан — в БД должен быть NULL (Valid=false).
|
||||||
|
if st.candidates[1].URL.Valid {
|
||||||
|
t.Error("candidate[1].URL must be NULL (Valid=false) for candidate without URL")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestChooseCandidate_PinsOverrides(t *testing.T) {
|
func TestChooseCandidate_PinsOverrides(t *testing.T) {
|
||||||
@@ -1050,6 +1143,24 @@ func TestReviewData_IncludesCandidates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestToStoreCandidates_URL(t *testing.T) {
|
||||||
|
// Кандидат с URL: URL должен быть проброшен как непустой NullString.
|
||||||
|
// Кандидат без URL: URL должен быть пустым NullString (Valid=false → NULL).
|
||||||
|
candURL := toStoreCandidates(1, []metadata.Candidate{
|
||||||
|
{Provider: "tmdb", ID: "603", Title: "With URL", URL: "https://www.themoviedb.org/movie/603"},
|
||||||
|
{Provider: "tvdb", ID: "1", Title: "Without URL", URL: ""},
|
||||||
|
})
|
||||||
|
if len(candURL) != 2 {
|
||||||
|
t.Fatalf("len = %d, want 2", len(candURL))
|
||||||
|
}
|
||||||
|
if c := candURL[0]; c.URL.String != "https://www.themoviedb.org/movie/603" || !c.URL.Valid {
|
||||||
|
t.Errorf("URL[0] = (%q, valid=%v), want (url, true)", c.URL.String, c.URL.Valid)
|
||||||
|
}
|
||||||
|
if c := candURL[1]; c.URL.String != "" || c.URL.Valid {
|
||||||
|
t.Errorf("URL[1] = (%q, valid=%v), want (\"\", false)", c.URL.String, c.URL.Valid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestToLayoutPlan(t *testing.T) {
|
func TestToLayoutPlan(t *testing.T) {
|
||||||
s, e := 1, 3
|
s, e := 1, 3
|
||||||
plan := recognize.Plan{
|
plan := recognize.Plan{
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ type Store interface {
|
|||||||
SetOverride(ctx context.Context, downloadID int64, field, value string) error
|
SetOverride(ctx context.Context, downloadID int64, field, value string) error
|
||||||
ListOverrides(ctx context.Context, downloadID int64) (map[string]string, error)
|
ListOverrides(ctx context.Context, downloadID int64) (map[string]string, error)
|
||||||
CreateFileLinks(ctx context.Context, links []store.FileLink) error
|
CreateFileLinks(ctx context.Context, links []store.FileLink) error
|
||||||
|
SupersedeForeignLinks(ctx context.Context, downloadID int64, dstPaths []string) error
|
||||||
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
|
||||||
|
|||||||
@@ -114,7 +114,10 @@ func (f *fakeStore) ListOverrides(_ context.Context, _ int64) (map[string]string
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (f *fakeStore) CreateFileLinks(_ context.Context, _ []store.FileLink) error { return nil }
|
func (f *fakeStore) CreateFileLinks(_ context.Context, _ []store.FileLink) error { return nil }
|
||||||
func (f *fakeStore) LatestBatchID(_ context.Context, _ int64) (string, error) { return "", nil }
|
func (f *fakeStore) SupersedeForeignLinks(_ context.Context, _ int64, _ []string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) LatestBatchID(_ context.Context, _ int64) (string, error) { return "", nil }
|
||||||
func (f *fakeStore) ListFileLinksByBatch(_ context.Context, _ string) ([]store.FileLink, error) {
|
func (f *fakeStore) ListFileLinksByBatch(_ context.Context, _ string) ([]store.FileLink, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-06-29
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Сейчас `metadata.Candidate` и `store.MetadataCandidate` хранят провайдера и
|
||||||
|
id, но не URL. Человек на ревью видит таблицу кандидатов с названием и id,
|
||||||
|
но чтобы проверить матч — должен вручную открыть сайт базы и вставить id в
|
||||||
|
поиск. Изменение добавляет поле `URL` на всех слоях: от клиентов метабаз до
|
||||||
|
шаблона ревью.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Каждый кандидат внешней базы несёт URL, по которому человек может
|
||||||
|
перейти и проверить матч
|
||||||
|
- URL генерируется клиентом провайдера при поиске (source of truth)
|
||||||
|
- Сохраняется в БД и отображается в веб-интерфейсе ревью
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Не меняем Telegram-бот (в чате кандидатов нет, выбор — через веб)
|
||||||
|
- Не добавляем отдельную колонку для нативного провайдера (всегда можно
|
||||||
|
вывести из URL или добавить позже)
|
||||||
|
- Не кешируем и не валидируем URL (не наша ответственность)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. URL генерируется клиентом провайдера при Search, а не на лету при отображении
|
||||||
|
|
||||||
|
**Почему:** клиент знает и нативный провайдер, и нативный id. При
|
||||||
|
отображении в ревью мы имеем только теговые provider/provider_id (TVMaze →
|
||||||
|
tvdb/269613), и нативный tvMaze-id уже потерян. Генерация при поиске
|
||||||
|
сохраняет точную ссылку.
|
||||||
|
|
||||||
|
**Альтернатива (отклонена):** генерировать URL на лету в HTTP-обработчике
|
||||||
|
из provider/provider_id. Не работает для TVMaze (provider в БД уже
|
||||||
|
подменён на теговый). Хранить же оба id ради одной ссылки — дороже, чем
|
||||||
|
одно поле URL.
|
||||||
|
|
||||||
|
### 2. URL — простая строка, без структуры (provider + path + id)
|
||||||
|
|
||||||
|
**Почему:** URL у каждого провайдера формируется по-разному (у TMDB зависит
|
||||||
|
от mediaType, у TVDB — dereferrer, у IMDb — `/title/`). Хранить готовую
|
||||||
|
строку проще, чем набор параметров + функцию сборки.
|
||||||
|
|
||||||
|
### 3. БД: новая миграция `0004_candidate_url.sql`
|
||||||
|
|
||||||
|
**Почему:** изменение схемы — стандартно через goose-миграцию. Миграция
|
||||||
|
только добавляет nullable колонку (без DEFAULT, обратная совместимость).
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **URL может измениться** (провайдер меняет структуру сайта) → ссылка
|
||||||
|
сломается. Вероятность низкая (TMDB/TVDB/IMDb не меняли схемы URL
|
||||||
|
годами). Если сломается — фикс в одном месте (клиент провайдера),
|
||||||
|
миграция не нужна.
|
||||||
|
- **Для старых кандидатов url будет пустым** → в шаблоне показываем ссылку
|
||||||
|
только если url не пустой; старые записи останутся без ссылки, новые
|
||||||
|
получат при следующем распознавании.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
При ревью человек видит кандидатов из внешних баз (TMDB/TVDB/TVMaze/IMDb), но не может быстро перейти на сайт базы и проверить, тот ли фильм/сериал был найден. Нужно добавить кликабельные ссылки на внешние сайты — чтобы за пару секунд убедиться в правильности матча, не копируя id вручную и не открывая поиск.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Генерация URL внешнего сайта для каждого кандидата (TMDB, TVDB, IMDb, TVMaze) на основе провайдера и id
|
||||||
|
- Сохранение URL в `metadata_candidate` (новая колонка `url`)
|
||||||
|
- Отображение ссылки в таблице кандидатов на странице ревью (веб-UI)
|
||||||
|
- Ссылка открывается в новой вкладке (`target="_blank"`)
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
<!-- None — изменение затрагивает только существующие потоки, новый capability не создаётся. -->
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `recognition`: кандидаты внешних баз (`Candidate`) теперь несут URL для перехода на сайт-источник; URL сохраняется в БД и отображается в интерфейсе ревью
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **БД**: миграция `0004_candidate_url.sql` — добавляет колонку `url TEXT` в `metadata_candidate`
|
||||||
|
- **Код**: `metadata.Candidate` (+ поле `URL`), клиенты TMDB/TVDB/TVMaze (заполнение URL при Search), `store.MetadataCandidate` (+ поле `URL`), HTTP API `candidateView` (+ поле `URL`), шаблон `review.html` (колонка со ссылкой)
|
||||||
|
- **API/транспорт**: внутреннее изменение, внешний API не затрагивается
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Кандидат несёт URL для внешней проверки
|
||||||
|
|
||||||
|
Каждый кандидат внешней базы метаданных (`metadata.Candidate`) SHALL нести
|
||||||
|
поле `URL` — ссылку на страницу элемента (фильма/сериала) на сайте
|
||||||
|
провайдера. URL SHALL формироваться клиентом провайдера при поиске
|
||||||
|
(`Search`) и сохраняться в таблице `metadata_candidate`. На странице ревью
|
||||||
|
URL SHALL отображаться кликабельной ссылкой, открывающейся в новой вкладке
|
||||||
|
браузера.
|
||||||
|
|
||||||
|
Формат URL для каждого провайдера:
|
||||||
|
|
||||||
|
- **TMDB**: `https://www.themoviedb.org/movie/{id}` (фильм) или
|
||||||
|
`https://www.themoviedb.org/tv/{id}` (сериал) — тип контента известен из
|
||||||
|
запроса `Query.Type`
|
||||||
|
- **TVDB**: `https://www.thetvdb.com/dereferrer/series/{id}`
|
||||||
|
- **TVMaze**: `https://www.tvmaze.com/shows/{id}` — URL SHALL использовать
|
||||||
|
нативный id TVMaze, а не внешний тег (TVDB/IMDb), чтобы ссылка вела на
|
||||||
|
TVMaze-страницу
|
||||||
|
|
||||||
|
#### Scenario: Кандидат TMDB с корректной ссылкой
|
||||||
|
|
||||||
|
- **GIVEN** TMDB найден кандидат-фильм с id `603` («Матрица»)
|
||||||
|
- **WHEN** клиент TMDB формирует Candidate
|
||||||
|
- **THEN** `URL` = `https://www.themoviedb.org/movie/603`
|
||||||
|
|
||||||
|
#### Scenario: Кандидат TVMaze с нативной ссылкой
|
||||||
|
|
||||||
|
- **GIVEN** TVMaze найден сериал с id `169` («Фарго»), внешний тег — TVDB id `269613`
|
||||||
|
- **WHEN** клиент TVMaze формирует Candidate
|
||||||
|
- **THEN** `URL` = `https://www.tvmaze.com/shows/169`
|
||||||
|
- **AND** `TagProvider`/`TagID` остаются `tvdb`/`269613` (тег папки Jellyfin не меняется)
|
||||||
|
|
||||||
|
#### Scenario: Ссылка в интерфейсе ревью
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в состоянии `review` с кандидатами, у которых заполнен `url`
|
||||||
|
- **WHEN** рендерится страница ревью
|
||||||
|
- **THEN** в таблице кандидатов каждый кандидат SHALL отображаться со
|
||||||
|
ссылкой на внешний сайт
|
||||||
|
- **AND** ссылка открывается в новой вкладке (`target="_blank"`)
|
||||||
|
- **AND** текстом ссылки служит провайдер или сокращённый url
|
||||||
|
|
||||||
|
#### Scenario: URL сохраняется в БД
|
||||||
|
|
||||||
|
- **GIVEN** результат поиска с кандидатами
|
||||||
|
- **WHEN** кандидаты сохраняются в таблицу `metadata_candidate`
|
||||||
|
- **THEN** значение `url` SHALL быть записано в колонку `url`
|
||||||
|
- **AND** при последующей загрузке данных ревью url доступен без повторной
|
||||||
|
генерации
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
## 1. Слой метаданных — генерация URL
|
||||||
|
|
||||||
|
- [x] 1.1 Добавить поле `URL` в структуру `metadata.Candidate` (`internal/metadata/metadata.go`)
|
||||||
|
- [x] 1.2 Заполнять `URL` в `TMDB.Search`: `https://www.themoviedb.org/movie/{id}` (фильм) или `https://www.themoviedb.org/tv/{id}` (сериал)
|
||||||
|
- [x] 1.3 Заполнять `URL` в `TVDB.Search`: `https://www.thetvdb.com/dereferrer/series/{id}`
|
||||||
|
- [x] 1.4 Заполнять `URL` в `TVMaze.Search`: `https://www.tvmaze.com/shows/{id}` (нативный id, не теговый)
|
||||||
|
- [x] 1.5 Обновить тесты клиентов метаданных (tmdb_test.go, tvdb_test.go, tvmaze_test.go) — проверить наличие URL в результатах Search
|
||||||
|
|
||||||
|
## 2. БД — хранение URL
|
||||||
|
|
||||||
|
- [x] 2.1 Создать миграцию `internal/store/migrations/0004_candidate_url.sql` — добавить колонку `url TEXT` в `metadata_candidate`
|
||||||
|
- [x] 2.2 Добавить поле `URL` (`sql.NullString`) в структуру `store.MetadataCandidate`
|
||||||
|
- [x] 2.3 Обновить `CreateCandidates` — сохранять `url` в INSERT
|
||||||
|
- [x] 2.4 Обновить `docs/specs/database.md` — актуализировать ER-схему (колонка `url`)
|
||||||
|
|
||||||
|
## 3. Конвертация candidate → store
|
||||||
|
|
||||||
|
- [x] 3.1 В `worker.toStoreCandidates` пробрасывать `URL` из `metadata.Candidate` в `store.MetadataCandidate`
|
||||||
|
|
||||||
|
## 4. HTTP API и шаблон
|
||||||
|
|
||||||
|
- [x] 4.1 Добавить поле `URL` в `candidateView` (internal/httpapi/review.go)
|
||||||
|
- [x] 4.2 Пробросить `URL` из `store.MetadataCandidate` в `candidateView` при сборе данных ревью
|
||||||
|
- [x] 4.3 В шаблоне `web/templates/review.html` добавить колонку «ссылка» в таблицу кандидатов с `<a target="_blank">`
|
||||||
|
|
||||||
|
## 5. Проверка
|
||||||
|
|
||||||
|
- [x] 5.1 `task test` — все тесты проходят
|
||||||
|
- [x] 5.2 `task lint` — без ошибок
|
||||||
|
- [x] 5.3 `openspec validate --strict` — валидация спеки
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-06-29
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Фоновая сверка (`internal/worker/reconcile.go`) выводит состояние уже
|
||||||
|
разложенной задачи из матрицы «источник × цель» (`deriveState`). Цель
|
||||||
|
определяется `targetPresent` — проверкой `os.Lstat` по `dst_path` ссылок
|
||||||
|
последнего батча со «статусом раскладки» (`isLaidOut`: `linked`/`copied`/
|
||||||
|
`exists`). Источник дебаунсится, цель — нет.
|
||||||
|
|
||||||
|
Проблема: `targetPresent` проверяет существование **пути**, а не
|
||||||
|
принадлежность лежащего там файла данной загрузке. Поэтому, когда другая
|
||||||
|
загрузка разложилась по тому же `dst_path` (например, повторная закачка
|
||||||
|
того же фильма в другом качестве), сверка прежней загрузки видит «цель
|
||||||
|
вернулась» и ложно выводит её из `deleted` в `orphaned` (с уведомлением).
|
||||||
|
|
||||||
|
`deleted` уже терминален для идемпотентности (`store.terminalStates`
|
||||||
|
включает его — снимается `idempotency_key`), но при этом всё ещё
|
||||||
|
присутствует в `desyncStates`, т.е. сверка его переоценивает.
|
||||||
|
|
||||||
|
Обе точки раскладки (авто-апплай после распознавания и ручной `Apply`)
|
||||||
|
сходятся в `linkPlan` (`internal/worker/review.go`), который вызывает
|
||||||
|
`store.CreateFileLinks` и затем `transition` в `done`.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Цель загрузки при сверке считается присутствующей только если файлы по её
|
||||||
|
путям — её собственная раскладка (инвариант «один путь — один владелец»).
|
||||||
|
- При повторной раскладке на освободившийся чужой путь владение переходит к
|
||||||
|
новой загрузке; прежние ссылки на этот путь выводятся из обращения.
|
||||||
|
- `deleted` перестаёт переоцениваться сверкой (терминален и в этом смысле).
|
||||||
|
- Документация (`docs/specs/`) и спека `state-reconciliation` приведены в
|
||||||
|
соответствие с графом состояний (`deleted --> [*]`).
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Мульти-версии Jellyfin (4K + 1080p рядом) — отдельная фича, movie-only,
|
||||||
|
не входит в этот change.
|
||||||
|
- Изменение поведения коллизии (занятый путь → review) — остаётся как есть.
|
||||||
|
- Идентификация цели по inode/устройству — сознательно отвергнута (см.
|
||||||
|
Decisions).
|
||||||
|
- Миграция `file-layout` в OpenSpec — вне рамок; правки раскладки идут в
|
||||||
|
`docs/specs/`.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Решение 1: владение выражаем статусом `file_link`, а не inode
|
||||||
|
|
||||||
|
Вводим статус `superseded` (`layout.StatusSuperseded`). При успешной
|
||||||
|
раскладке в `linkPlan` после `CreateFileLinks` помечаем чужие ссылки на те
|
||||||
|
же `dst_path`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE file_link SET status = 'superseded'
|
||||||
|
WHERE dst_path = ? AND download_id != ? AND status IN ('linked','copied','exists')
|
||||||
|
```
|
||||||
|
|
||||||
|
`targetPresent`/`isLaidOut` уже считают целью только `linked`/`copied`/
|
||||||
|
`exists`, поэтому `superseded` отсекается **без изменений** в логике сверки.
|
||||||
|
Помечаем только пути, которые сами реально разложили (результаты со статусом
|
||||||
|
из `isLaidOut`) — коллизии и пропуски владение не отбирают.
|
||||||
|
|
||||||
|
**Почему не inode:** завязка на номер inode — низкоуровневая, не выражает
|
||||||
|
домен, требует хранить и сверять числа, осмысленные только для ФС, и
|
||||||
|
усложняет тесты. Статус `file_link` остаётся в нашей доменной модели и
|
||||||
|
переиспользует существующую логику `isLaidOut`. Миграция схемы не нужна:
|
||||||
|
`file_link.status` — `TEXT`.
|
||||||
|
|
||||||
|
### Решение 2: `deleted` вне сверки
|
||||||
|
|
||||||
|
Убираем `store.StateDeleted` из `desyncStates`. Завершённую начисто задачу
|
||||||
|
больше не переоценивают. Это безопасно: источник к терминальной задаче не
|
||||||
|
вернётся (идемпотентность снимается только для активных), а цель отбирается
|
||||||
|
переходом владения (Решение 1) — оба пути «воскрешения» закрыты.
|
||||||
|
|
||||||
|
### Решение 3: точка вызова supersede — `linkPlan`
|
||||||
|
|
||||||
|
`linkPlan` — единственная воронка обеих раскладок (авто и ручной `Apply`).
|
||||||
|
Supersede вызываем там после фиксации своих ссылок и до/рядом с переходом в
|
||||||
|
`done`, под тем же `w.mu`, в той же логической операции. Отдельный метод
|
||||||
|
стора (напр. `SupersedeForeignLinks(ctx, downloadID, dstPaths)`).
|
||||||
|
|
||||||
|
### Решение 4: приведение спеки и доков
|
||||||
|
|
||||||
|
- `state-reconciliation` (дельта этого change): сверка не трогает `deleted`;
|
||||||
|
присутствие цели — по владению; самовосстановление из `deleted` убрано.
|
||||||
|
- `docs/specs/workflow.md` — в «Сверке с реальностью» уточнить, что
|
||||||
|
`deleted` терминален (без healing); граф уже это рисует.
|
||||||
|
- `docs/specs/jellyfin-layout.md` — добавить переход владения путём при
|
||||||
|
повторной раскладке на освободившийся путь.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **Гонок нет:** и раскладка, и сверка идут под `w.mu` (per-worker),
|
||||||
|
supersede и `CreateFileLinks` — последовательно в `linkPlan`.
|
||||||
|
- **Частичное владение** (сериал, где новая раскладка заняла лишь часть
|
||||||
|
путей прежней): прежняя загрузка теряет владение только перехваченными
|
||||||
|
путями. Пока хоть одна её неперехваченная ссылка (`isLaidOut`)
|
||||||
|
существует на ФС, `targetPresent` возвращает true и задача остаётся
|
||||||
|
`done`; в `target_missing` она уйдёт, лишь когда пропадут и собственные
|
||||||
|
файлы. Это корректное отражение реальности, не регресс.
|
||||||
|
- **Старые данные:** ранее ложно «воскрешённые» задачи в БД останутся в
|
||||||
|
своём состоянии до следующего тика; после деплоя `deleted`-задачи просто
|
||||||
|
перестанут трогаться, а ошибочно ставшие `orphaned` исправятся вручную при
|
||||||
|
необходимости (точечно, не автоматической миграцией — инцидент единичный).
|
||||||
|
- **`superseded` — новое значение enum-а:** учесть в местах, где статус
|
||||||
|
интерпретируется (undo/листинги UI), чтобы такие ссылки не показывались
|
||||||
|
как активная цель и не участвовали в undo как «снимаемые».
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Фоновая сверка может ложно «воскресить» завершённую загрузку, если её
|
||||||
|
целевой путь переиспользовала другая загрузка. Реальный инцидент: фильм
|
||||||
|
скачали в 4K (download A), затем удалили его из qBittorrent и Jellyfin —
|
||||||
|
сверка увела A в терминальный `deleted` (нет ни источника, ни цели). После
|
||||||
|
этого тот же фильм скачали в 1080p (download B); он распознался как тот же
|
||||||
|
фильм и сделал хардлинк по **тому же** `dst_path`. На следующем тике сверка
|
||||||
|
для A увидела, что файл по пути снова существует (хотя это файл B), и
|
||||||
|
вывела `deleted → orphaned` с ложным уведомлением «источник потерян».
|
||||||
|
|
||||||
|
Корень: `targetPresent` считает цель присутствующей по факту существования
|
||||||
|
**пути**, не проверяя, что лежащая там раскладка принадлежит **этой**
|
||||||
|
загрузке. Один `dst_path` может оказаться «своим» сразу для двух загрузок.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Вводим инвариант **«один целевой путь — один владелец»**: цель загрузки
|
||||||
|
считается присутствующей, только если разложенные по её путям ссылки всё
|
||||||
|
ещё принадлежат именно ей. Когда новая раскладка ложится на путь, ранее
|
||||||
|
занятый другой загрузкой (путь к тому моменту свободен — иначе была бы
|
||||||
|
коллизия → review), владение переходит к новой загрузке, а ссылки прежней
|
||||||
|
на этот путь помечаются вышедшими из обращения (новый статус `file_link`
|
||||||
|
— `superseded`).
|
||||||
|
- Делаем `deleted` действительно **терминальным** для сверки: исключаем его
|
||||||
|
из набора сверяемых состояний — завершённую начисто задачу больше не
|
||||||
|
переоценивают (источник к ней не вернётся из-за идемпотентности
|
||||||
|
терминальных задач, а цель отбирается переходом владения).
|
||||||
|
- Приводим спеку в соответствие с графом состояний: убираем из требования
|
||||||
|
«самовосстановление» возврат из `deleted` (он противоречил диаграмме
|
||||||
|
`deleted --> [*]` в `workflow.md` и под новым инвариантом нереализуем).
|
||||||
|
- Коллизия остаётся как есть: если файл прежней загрузки **всё ещё на
|
||||||
|
месте**, новая раскладка не перезаписывает его, а уходит в review.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
(нет)
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `state-reconciliation`: присутствие цели определяется по **владению**, а
|
||||||
|
не по факту существования пути; вводится переход владения путём при
|
||||||
|
повторной раскладке; `deleted` исключается из сверки и из
|
||||||
|
самовосстановления.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Затрагиваемый код:**
|
||||||
|
- `internal/worker/reconcile.go` — убрать `StateDeleted` из
|
||||||
|
`desyncStates`; `targetPresent`/`isLaidOut` уже считают целью только
|
||||||
|
`linked/copied/exists`, новый статус `superseded` отсекается
|
||||||
|
автоматически.
|
||||||
|
- `internal/worker/review.go` (`linkPlan`) — после фиксации своих ссылок
|
||||||
|
пометить чужие `file_link` на тех же `dst_path` как `superseded`;
|
||||||
|
покрывает и авто-раскладку, и ручной `Apply` (обе идут через
|
||||||
|
`linkPlan`).
|
||||||
|
- `internal/store/recognition.go` — новый метод стора (supersede чужих
|
||||||
|
ссылок по списку `dst_path`).
|
||||||
|
- `internal/layout/layout.go` — константа статуса `StatusSuperseded`.
|
||||||
|
- **Без миграции схемы:** `file_link.status` — `TEXT`, новое значение
|
||||||
|
enum-а не меняет таблицу. ER-схема `docs/specs/database.md` не меняется.
|
||||||
|
- **Документация:** `docs/specs/workflow.md` (раздел «Сверка с
|
||||||
|
реальностью» — `deleted` терминален) и `docs/specs/jellyfin-layout.md`
|
||||||
|
(переход владения путём при повторной раскладке) — `file-layout` ещё не
|
||||||
|
перенесён в OpenSpec, источник истины по нему — `docs/specs/`.
|
||||||
|
- **Поведение пользователя:** исчезают ложные уведомления `orphaned`/
|
||||||
|
`target_missing` по уже удалённым загрузкам; повторная закачка того же
|
||||||
|
фильма в другом качестве больше не «трогает» прежнюю задачу.
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Владение целевым путём — один путь, один владелец
|
||||||
|
|
||||||
|
Целевой путь раскладки (`file_link.dst_path`) SHALL принадлежать не более
|
||||||
|
чем одной загрузке одновременно. При успешной раскладке загрузки на путь,
|
||||||
|
который ранее заняла **другая** загрузка, владение SHALL переходить к новой
|
||||||
|
загрузке: ссылки прежней загрузки на тот же `dst_path` система SHALL
|
||||||
|
помечать вышедшими из обращения (статус, не относящийся к разложенной цели),
|
||||||
|
после чего они перестают считаться целью прежней загрузки при сверке.
|
||||||
|
|
||||||
|
Присутствие цели при сверке SHALL определяться по **владению**, а не по
|
||||||
|
факту существования пути: цель загрузки считается присутствующей, только
|
||||||
|
если существующие на ФС файлы по её путям — это ссылки, всё ещё
|
||||||
|
принадлежащие этой загрузке (не вышедшие из обращения). Файл, лежащий по
|
||||||
|
тому же пути, но созданный другой загрузкой, целью первой загрузки
|
||||||
|
считаться SHALL NOT.
|
||||||
|
|
||||||
|
Переход владения возможен лишь когда путь к моменту раскладки **свободен**
|
||||||
|
(прежний файл уже удалён): занятый реальным файлом путь по-прежнему даёт
|
||||||
|
коллизию и уходит в review (новая раскладка не перезаписывает чужой файл).
|
||||||
|
|
||||||
|
#### Scenario: Повторная закачка забирает освободившийся путь
|
||||||
|
|
||||||
|
- **GIVEN** загрузка A разложена по пути P, но её файл по P удалён вручную
|
||||||
|
- **WHEN** загрузка B успешно раскладывается по тому же пути P
|
||||||
|
- **THEN** ссылки A на P помечаются вышедшими из обращения
|
||||||
|
- **AND** при сверке цель A по пути P считается отсутствующей
|
||||||
|
|
||||||
|
#### Scenario: Чужой файл по пути не считается своей целью
|
||||||
|
|
||||||
|
- **GIVEN** по пути P лежит файл, созданный загрузкой B
|
||||||
|
- **WHEN** сверка проверяет присутствие цели загрузки A, чьи ссылки на P
|
||||||
|
вышли из обращения
|
||||||
|
- **THEN** цель A считается отсутствующей, несмотря на существование файла
|
||||||
|
по P
|
||||||
|
|
||||||
|
#### Scenario: Занятый путь даёт коллизию, а не переход владения
|
||||||
|
|
||||||
|
- **GIVEN** файл загрузки A по пути P всё ещё существует
|
||||||
|
- **WHEN** загрузка B пытается разложиться по тому же пути P
|
||||||
|
- **THEN** возникает коллизия и B уходит в review
|
||||||
|
- **AND** владение путём P за A не отбирается
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Периодическая сверка состояния с реальностью
|
||||||
|
|
||||||
|
`worker` SHALL периодически (на тике поллинга) сверять задачи, для которых
|
||||||
|
ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и
|
||||||
|
выводить состояние задачи из двух независимых признаков: присутствия
|
||||||
|
**источника** (раздача с `download.infohash` в выдаче qBittorrent) и
|
||||||
|
присутствия **цели** (см. требование о владении целевым путём: существуют все
|
||||||
|
ссылки последнего батча со статусом раскладки, всё ещё принадлежащие этой
|
||||||
|
загрузке).
|
||||||
|
|
||||||
|
Сверке SHALL подвергаться только состояния `done`, `target_missing`,
|
||||||
|
`orphaned`. Состояние `deleted` сверка трогать SHALL NOT — оно терминально.
|
||||||
|
Активные (`downloading`/`recognizing`/`review`/`deferred`/`linking`) и
|
||||||
|
пользовательски-терминальные (`reverted`/`cancelled`/`failed`/`stuck`)
|
||||||
|
состояния сверка трогать SHALL NOT.
|
||||||
|
|
||||||
|
Состояние SHALL переписываться только при его изменении (без записи и логов,
|
||||||
|
когда выведенное состояние совпадает с текущим).
|
||||||
|
|
||||||
|
#### Scenario: Источник и цель на месте — состояние не меняется
|
||||||
|
|
||||||
|
- **WHEN** для задачи в `done` раздача присутствует в qBittorrent и все её
|
||||||
|
разложенные хардлинки существуют
|
||||||
|
- **THEN** задача остаётся в `done`
|
||||||
|
- **AND** запись состояния и лог перехода не выполняются
|
||||||
|
|
||||||
|
#### Scenario: Частичная пропажа цели считается отсутствием
|
||||||
|
|
||||||
|
- **WHEN** часть разложенных хардлинков задачи удалена, а источник на месте
|
||||||
|
- **THEN** цель считается отсутствующей и задача переходит в `target_missing`
|
||||||
|
|
||||||
|
#### Scenario: Задача в deleted сверкой не переоценивается
|
||||||
|
|
||||||
|
- **WHEN** задача находится в `deleted`
|
||||||
|
- **THEN** сверка её не рассматривает и состояние не меняет, даже если по её
|
||||||
|
бывшему пути появился файл другой загрузки
|
||||||
|
|
||||||
|
### Requirement: Состояние deleted при пропаже источника и цели
|
||||||
|
|
||||||
|
Когда отсутствуют и источник (с учётом дебаунса), и цель, система SHALL
|
||||||
|
переводить задачу в состояние `deleted`. `deleted` терминально: действий над
|
||||||
|
задачей больше нет, и сверка её больше не переоценивает (источник к
|
||||||
|
терминальной задаче не возвращается из-за идемпотентности, а цель отбирается
|
||||||
|
переходом владения путём к другой загрузке).
|
||||||
|
|
||||||
|
#### Scenario: Источник и цель удалены
|
||||||
|
|
||||||
|
- **WHEN** сверка устойчиво не находит раздачу в qBittorrent и разложенных
|
||||||
|
хардлинков задачи на ФС больше нет
|
||||||
|
- **THEN** задача переходит в `deleted`
|
||||||
|
|
||||||
|
#### Scenario: deleted не воскресает при переиспользовании пути
|
||||||
|
|
||||||
|
- **GIVEN** задача A в `deleted`
|
||||||
|
- **WHEN** другая задача раскладывается по бывшему пути A
|
||||||
|
- **THEN** задача A остаётся в `deleted` (не переходит в `orphaned`)
|
||||||
|
|
||||||
|
### Requirement: Самовосстановление состояния при возврате реальности
|
||||||
|
|
||||||
|
Система SHALL возвращать задачу в согласованное состояние, когда реальность
|
||||||
|
восстановилась (состояние выводится из текущей матрицы «источник × цель»):
|
||||||
|
при возврате источника и/или цели задача SHALL переходить из
|
||||||
|
`orphaned`/`target_missing` обратно (в т.ч. в `done`, когда присутствуют
|
||||||
|
оба). Из терминального `deleted` самовосстановления SHALL NOT быть.
|
||||||
|
|
||||||
|
#### Scenario: Источник вернулся
|
||||||
|
|
||||||
|
- **WHEN** для задачи в `orphaned` раздача снова появилась в qBittorrent, а
|
||||||
|
цель по-прежнему на месте
|
||||||
|
- **THEN** задача возвращается в `done`
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
## 1. Статус file_link и стор
|
||||||
|
|
||||||
|
- [x] 1.1 Добавить `StatusSuperseded LinkStatus = "superseded"` в
|
||||||
|
`internal/layout/layout.go` (вокабуляр статусов `file_link`).
|
||||||
|
- [x] 1.2 Добавить метод стора `SupersedeForeignLinks(ctx, downloadID int64, dstPaths []string) error`
|
||||||
|
в `internal/store/recognition.go`: `UPDATE file_link SET status='superseded'
|
||||||
|
WHERE dst_path IN (...) AND download_id != ? AND status IN ('linked','copied','exists')`.
|
||||||
|
Пустой `dstPaths` — no-op. Объявить метод в интерфейсе стора в `internal/worker/worker.go`.
|
||||||
|
|
||||||
|
## 2. Переход владения при раскладке
|
||||||
|
|
||||||
|
- [x] 2.1 В `linkPlan` (`internal/worker/review.go`) после успешного
|
||||||
|
`CreateFileLinks` собрать `dst_path` фактически разложенных ссылок
|
||||||
|
(статус из `isLaidOut`: `linked`/`copied`/`exists`) и вызвать
|
||||||
|
`SupersedeForeignLinks(ctx, d.ID, paths)` до перехода в `done`.
|
||||||
|
- [x] 2.2 Убедиться, что покрыты обе воронки раскладки (авто-апплай и ручной
|
||||||
|
`Apply`) — обе идут через `linkPlan`.
|
||||||
|
|
||||||
|
## 3. deleted вне сверки
|
||||||
|
|
||||||
|
- [x] 3.1 Убрать `store.StateDeleted` из `desyncStates`
|
||||||
|
(`internal/worker/reconcile.go`). `terminalStates`/`IsTerminal`
|
||||||
|
(`internal/store/download.go`) не трогаем — `deleted` там уже есть.
|
||||||
|
|
||||||
|
## 4. Аудит потребителей статуса
|
||||||
|
|
||||||
|
- [x] 4.1 Проверить места, читающие `file_link.status` (undo в
|
||||||
|
`internal/worker/review.go`/`internal/layout`, листинги UI в
|
||||||
|
`internal/httpapi`): `superseded`-ссылки не должны считаться активной
|
||||||
|
целью и не должны попадать в undo как «снимаемые». Поправить при
|
||||||
|
необходимости.
|
||||||
|
|
||||||
|
## 5. Тесты
|
||||||
|
|
||||||
|
- [x] 5.1 Тест сверки: задача в `deleted` не переоценивается, даже если по
|
||||||
|
её бывшему пути появился файл (нет перехода `deleted → orphaned`).
|
||||||
|
- [x] 5.2 Тест раскладки: повторная раскладка по освободившемуся чужому пути
|
||||||
|
помечает прежние ссылки `superseded`; `targetPresent` прежней загрузки →
|
||||||
|
`false`.
|
||||||
|
- [x] 5.3 Тест: занятый реальным файлом путь даёт коллизию → review,
|
||||||
|
владение не отбирается.
|
||||||
|
- [x] 5.4 Тест: загрузка не «суперсидит» сама себя (`download_id != self`).
|
||||||
|
|
||||||
|
## 6. Документация
|
||||||
|
|
||||||
|
- [x] 6.1 `docs/specs/workflow.md` («Сверка с реальностью») — `deleted`
|
||||||
|
терминален, без самовосстановления; согласовать с графом `deleted --> [*]`.
|
||||||
|
- [x] 6.2 `docs/specs/jellyfin-layout.md` — добавить переход владения целевым
|
||||||
|
путём при повторной раскладке на освободившийся путь.
|
||||||
|
|
||||||
|
## 7. Проверки
|
||||||
|
|
||||||
|
- [x] 7.1 `task test` и `task lint` зелёные.
|
||||||
|
- [x] 7.2 `openspec validate target-path-ownership --strict` проходит.
|
||||||
@@ -92,3 +92,53 @@ gracefully использует доступные названия.
|
|||||||
- **GIVEN** план с названием «Тёмный рыцарь» и кандидат базы «Темный рыцарь»
|
- **GIVEN** план с названием «Тёмный рыцарь» и кандидат базы «Темный рыцарь»
|
||||||
- **WHEN** сравниваются нормализованные названия
|
- **WHEN** сравниваются нормализованные названия
|
||||||
- **THEN** они считаются совпадающими
|
- **THEN** они считаются совпадающими
|
||||||
|
|
||||||
|
### Requirement: Кандидат несёт URL для внешней проверки
|
||||||
|
|
||||||
|
Каждый кандидат внешней базы метаданных (`metadata.Candidate`) SHALL нести
|
||||||
|
поле `URL` — ссылку на страницу элемента (фильма/сериала) на сайте
|
||||||
|
провайдера. URL SHALL формироваться клиентом провайдера при поиске
|
||||||
|
(`Search`) и сохраняться в таблице `metadata_candidate`. На странице ревью
|
||||||
|
URL SHALL отображаться кликабельной ссылкой, открывающейся в новой вкладке
|
||||||
|
браузера.
|
||||||
|
|
||||||
|
Формат URL для каждого провайдера:
|
||||||
|
|
||||||
|
- **TMDB**: `https://www.themoviedb.org/movie/{id}` (фильм) или
|
||||||
|
`https://www.themoviedb.org/tv/{id}` (сериал) — тип контента известен из
|
||||||
|
запроса `Query.Type`
|
||||||
|
- **TVDB**: `https://www.thetvdb.com/dereferrer/series/{id}`
|
||||||
|
- **TVMaze**: `https://www.tvmaze.com/shows/{id}` — URL SHALL использовать
|
||||||
|
нативный id TVMaze, а не внешний тег (TVDB/IMDb), чтобы ссылка вела на
|
||||||
|
TVMaze-страницу
|
||||||
|
|
||||||
|
#### Scenario: Кандидат TMDB с корректной ссылкой
|
||||||
|
|
||||||
|
- **GIVEN** TMDB найден кандидат-фильм с id `603` («Матрица»)
|
||||||
|
- **WHEN** клиент TMDB формирует Candidate
|
||||||
|
- **THEN** `URL` = `https://www.themoviedb.org/movie/603`
|
||||||
|
|
||||||
|
#### Scenario: Кандидат TVMaze с нативной ссылкой
|
||||||
|
|
||||||
|
- **GIVEN** TVMaze найден сериал с id `169` («Фарго»), внешний тег — TVDB id `269613`
|
||||||
|
- **WHEN** клиент TVMaze формирует Candidate
|
||||||
|
- **THEN** `URL` = `https://www.tvmaze.com/shows/169`
|
||||||
|
- **AND** `TagProvider`/`TagID` остаются `tvdb`/`269613` (тег папки Jellyfin не меняется)
|
||||||
|
|
||||||
|
#### Scenario: Ссылка в интерфейсе ревью
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в состоянии `review` с кандидатами, у которых заполнен `url`
|
||||||
|
- **WHEN** рендерится страница ревью
|
||||||
|
- **THEN** в таблице кандидатов каждый кандидат SHALL отображаться со
|
||||||
|
ссылкой на внешний сайт
|
||||||
|
- **AND** ссылка открывается в новой вкладке (`target="_blank"`)
|
||||||
|
- **AND** текстом ссылки служит провайдер или сокращённый url
|
||||||
|
|
||||||
|
#### Scenario: URL сохраняется в БД
|
||||||
|
|
||||||
|
- **GIVEN** результат поиска с кандидатами
|
||||||
|
- **WHEN** кандидаты сохраняются в таблицу `metadata_candidate`
|
||||||
|
- **THEN** значение `url` SHALL быть записано в колонку `url`
|
||||||
|
- **AND** при последующей загрузке данных ревью url доступен без повторной
|
||||||
|
генерации
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,15 @@ qBittorrent. Capability описывает периодическую и при
|
|||||||
ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и
|
ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и
|
||||||
выводить состояние задачи из двух независимых признаков: присутствия
|
выводить состояние задачи из двух независимых признаков: присутствия
|
||||||
**источника** (раздача с `download.infohash` в выдаче qBittorrent) и
|
**источника** (раздача с `download.infohash` в выдаче qBittorrent) и
|
||||||
присутствия **цели** (все `file_link` со `status = linked` существуют на ФС).
|
присутствия **цели** (см. требование о владении целевым путём: существуют все
|
||||||
|
ссылки последнего батча со статусом раскладки, всё ещё принадлежащие этой
|
||||||
|
загрузке).
|
||||||
|
|
||||||
Сверке SHALL подвергаться только состояния `done`, `target_missing`,
|
Сверке SHALL подвергаться только состояния `done`, `target_missing`,
|
||||||
`orphaned`, `deleted`. Активные (`downloading`/`recognizing`/`review`/
|
`orphaned`. Состояние `deleted` сверка трогать SHALL NOT — оно терминально.
|
||||||
`deferred`/`linking`) и пользовательски-терминальные (`reverted`/`cancelled`/
|
Активные (`downloading`/`recognizing`/`review`/`deferred`/`linking`) и
|
||||||
`failed`/`stuck`) состояния сверка трогать SHALL NOT.
|
пользовательски-терминальные (`reverted`/`cancelled`/`failed`/`stuck`)
|
||||||
|
состояния сверка трогать SHALL NOT.
|
||||||
|
|
||||||
Состояние SHALL переписываться только при его изменении (без записи и логов,
|
Состояние SHALL переписываться только при его изменении (без записи и логов,
|
||||||
когда выведенное состояние совпадает с текущим).
|
когда выведенное состояние совпадает с текущим).
|
||||||
@@ -40,6 +43,12 @@ qBittorrent. Capability описывает периодическую и при
|
|||||||
- **WHEN** часть разложенных хардлинков задачи удалена, а источник на месте
|
- **WHEN** часть разложенных хардлинков задачи удалена, а источник на месте
|
||||||
- **THEN** цель считается отсутствующей и задача переходит в `target_missing`
|
- **THEN** цель считается отсутствующей и задача переходит в `target_missing`
|
||||||
|
|
||||||
|
#### Scenario: Задача в deleted сверкой не переоценивается
|
||||||
|
|
||||||
|
- **WHEN** задача находится в `deleted`
|
||||||
|
- **THEN** сверка её не рассматривает и состояние не меняет, даже если по её
|
||||||
|
бывшему пути появился файл другой загрузки
|
||||||
|
|
||||||
### Requirement: Принудительная проверка источника/цели перед действием
|
### Requirement: Принудительная проверка источника/цели перед действием
|
||||||
|
|
||||||
Команда workflow, требующая наличия источника или цели, SHALL синхронно
|
Команда workflow, требующая наличия источника или цели, SHALL синхронно
|
||||||
@@ -117,8 +126,10 @@ NOT полагаться только на фоновую сверку `worker`
|
|||||||
### Requirement: Состояние deleted при пропаже источника и цели
|
### Requirement: Состояние deleted при пропаже источника и цели
|
||||||
|
|
||||||
Когда отсутствуют и источник (с учётом дебаунса), и цель, система SHALL
|
Когда отсутствуют и источник (с учётом дебаунса), и цель, система SHALL
|
||||||
переводить задачу в состояние `deleted`. В `deleted` действий над задачей
|
переводить задачу в состояние `deleted`. `deleted` терминально: действий над
|
||||||
больше нет.
|
задачей больше нет, и сверка её больше не переоценивает (источник к
|
||||||
|
терминальной задаче не возвращается из-за идемпотентности, а цель отбирается
|
||||||
|
переходом владения путём к другой загрузке).
|
||||||
|
|
||||||
#### Scenario: Источник и цель удалены
|
#### Scenario: Источник и цель удалены
|
||||||
|
|
||||||
@@ -126,6 +137,54 @@ NOT полагаться только на фоновую сверку `worker`
|
|||||||
хардлинков задачи на ФС больше нет
|
хардлинков задачи на ФС больше нет
|
||||||
- **THEN** задача переходит в `deleted`
|
- **THEN** задача переходит в `deleted`
|
||||||
|
|
||||||
|
#### Scenario: deleted не воскресает при переиспользовании пути
|
||||||
|
|
||||||
|
- **GIVEN** задача A в `deleted`
|
||||||
|
- **WHEN** другая задача раскладывается по бывшему пути A
|
||||||
|
- **THEN** задача A остаётся в `deleted` (не переходит в `orphaned`)
|
||||||
|
|
||||||
|
### Requirement: Владение целевым путём — один путь, один владелец
|
||||||
|
|
||||||
|
Целевой путь раскладки (`file_link.dst_path`) SHALL принадлежать не более
|
||||||
|
чем одной загрузке одновременно. При успешной раскладке загрузки на путь,
|
||||||
|
который ранее заняла **другая** загрузка, владение SHALL переходить к новой
|
||||||
|
загрузке: ссылки прежней загрузки на тот же `dst_path` система SHALL
|
||||||
|
помечать вышедшими из обращения (статус, не относящийся к разложенной цели),
|
||||||
|
после чего они перестают считаться целью прежней загрузки при сверке.
|
||||||
|
|
||||||
|
Присутствие цели при сверке SHALL определяться по **владению**, а не по
|
||||||
|
факту существования пути: цель загрузки считается присутствующей, только
|
||||||
|
если существующие на ФС файлы по её путям — это ссылки, всё ещё
|
||||||
|
принадлежащие этой загрузке (не вышедшие из обращения). Файл, лежащий по
|
||||||
|
тому же пути, но созданный другой загрузкой, целью первой загрузки
|
||||||
|
считаться SHALL NOT.
|
||||||
|
|
||||||
|
Переход владения возможен лишь когда путь к моменту раскладки **свободен**
|
||||||
|
(прежний файл уже удалён): занятый реальным файлом путь по-прежнему даёт
|
||||||
|
коллизию и уходит в review (новая раскладка не перезаписывает чужой файл).
|
||||||
|
|
||||||
|
#### Scenario: Повторная закачка забирает освободившийся путь
|
||||||
|
|
||||||
|
- **GIVEN** загрузка A разложена по пути P, но её файл по P удалён вручную
|
||||||
|
- **WHEN** загрузка B успешно раскладывается по тому же пути P
|
||||||
|
- **THEN** ссылки A на P помечаются вышедшими из обращения
|
||||||
|
- **AND** при сверке цель A по пути P считается отсутствующей
|
||||||
|
|
||||||
|
#### Scenario: Чужой файл по пути не считается своей целью
|
||||||
|
|
||||||
|
- **GIVEN** по пути P лежит файл, созданный загрузкой B
|
||||||
|
- **WHEN** сверка проверяет присутствие цели загрузки A, чьи ссылки на P
|
||||||
|
вышли из обращения
|
||||||
|
- **THEN** цель A считается отсутствующей, несмотря на существование файла
|
||||||
|
по P
|
||||||
|
|
||||||
|
#### Scenario: Занятый путь даёт коллизию, а не переход владения
|
||||||
|
|
||||||
|
- **GIVEN** файл загрузки A по пути P всё ещё существует
|
||||||
|
- **WHEN** загрузка B пытается разложиться по тому же пути P
|
||||||
|
- **THEN** возникает коллизия и B уходит в review
|
||||||
|
- **AND** владение путём P за A не отбирается
|
||||||
|
|
||||||
### Requirement: Дебаунс пропажи источника
|
### Requirement: Дебаунс пропажи источника
|
||||||
|
|
||||||
Система SHALL дебаунсить только **отсутствие источника**, чтобы временная
|
Система SHALL дебаунсить только **отсутствие источника**, чтобы временная
|
||||||
@@ -154,8 +213,8 @@ SHALL сбрасывать счётчик пропусков.
|
|||||||
Система SHALL возвращать задачу в согласованное состояние, когда реальность
|
Система SHALL возвращать задачу в согласованное состояние, когда реальность
|
||||||
восстановилась (состояние выводится из текущей матрицы «источник × цель»):
|
восстановилась (состояние выводится из текущей матрицы «источник × цель»):
|
||||||
при возврате источника и/или цели задача SHALL переходить из
|
при возврате источника и/или цели задача SHALL переходить из
|
||||||
`orphaned`/`target_missing`/`deleted` обратно (в т.ч. в `done`, когда
|
`orphaned`/`target_missing` обратно (в т.ч. в `done`, когда присутствуют
|
||||||
присутствуют оба).
|
оба). Из терминального `deleted` самовосстановления SHALL NOT быть.
|
||||||
|
|
||||||
#### Scenario: Источник вернулся
|
#### Scenario: Источник вернулся
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
|
|
||||||
{{if .Candidates}}
|
{{if .Candidates}}
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>провайдер</th><th>название</th><th>год</th><th>id</th><th></th></tr></thead>
|
<thead><tr><th>провайдер</th><th>название</th><th>год</th><th>id</th><th>ссылка</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Candidates}}
|
{{range .Candidates}}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -83,6 +83,7 @@
|
|||||||
<td>{{.Title}}</td>
|
<td>{{.Title}}</td>
|
||||||
<td>{{if .Year}}{{.Year}}{{end}}</td>
|
<td>{{if .Year}}{{.Year}}{{end}}</td>
|
||||||
<td class="src">{{.ProviderID}}</td>
|
<td class="src">{{.ProviderID}}</td>
|
||||||
|
<td>{{if .URL}}<a href="{{.URL}}" target="_blank" rel="noopener">{{.Provider}}</a>{{end}}</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="post" action="/ui/downloads/{{$.ID}}/candidate">
|
<form method="post" action="/ui/downloads/{{$.ID}}/candidate">
|
||||||
<input type="hidden" name="candidate_id" value="{{.ID}}">
|
<input type="hidden" name="candidate_id" value="{{.ID}}">
|
||||||
|
|||||||
Reference in New Issue
Block a user