Compare commits

...
2 Commits
Author SHA1 Message Date
avandClaude dfa182a5a9 Ссылки на внешние базы в ревью (recognition)
Каждый кандидат внешней базы метаданных (TMDB/TVDB/TVMaze) теперь несёт
URL на страницу элемента — при ревью можно кликнуть и проверить матч.
URL формируется клиентом провайдера при поиске, сохраняется в БД
(metadata_candidate.url) и отображается ссылкой в веб-интерфейсе.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-29 20:09:20 +03:00
avandClaude Opus 4.8 6b7c090ce4 Владение целевым путём при повторной раскладке (state-reconciliation)
Завершённая загрузка ложно «воскресала» из deleted в orphaned, когда её
целевой путь переиспользовала другая загрузка (повторная закачка того же
фильма в другом качестве): сверка проверяла лишь существование пути, не
проверяя, что файл по нему — наша раскладка.

Вводим инвариант «один целевой путь — один владелец»:

- при успешной раскладке на освободившийся чужой путь владение переходит
  к новой загрузке — прежние file_link на этот путь помечаются статусом
  superseded и перестают считаться целью при сверке;
- deleted исключён из desyncStates — терминальное состояние больше не
  переоценивается (источник к нему не вернётся из-за идемпотентности,
  цель отбирается переходом владения);
- Undo снимает только реально свои разложенные ссылки (superseded
  пропускает — файл по пути теперь чужой хардлинк);
- ошибку перехода владения трактуем как некритичную (WARN-and-continue):
  файлы уже разложены, рассинхрон чужих задач исправит следующий тик.

Без миграции схемы (status — TEXT). Дельта влита в основную спеку,
обновлены workflow.md и jellyfin-layout.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 18:10:05 +03:00
34 changed files with 936 additions and 29 deletions
+1
View File
@@ -80,6 +80,7 @@ erDiagram
TEXT provider_id "NOT NULL"
TEXT title "nullable"
INTEGER year "nullable"
TEXT url "nullable; ссылка на страницу на сайте провайдера"
INTEGER chosen "NOT NULL DEFAULT 0; 0/1"
TEXT created_at "NOT NULL DEFAULT datetime('now')"
}
+13
View File
@@ -51,6 +51,19 @@ inode общий — диск не дублируется.
же inode → готово; другой файл → коллизия → review). Инварианты и undo —
в [architecture.md](architecture.md) → «Раскладка файлов».
## Владение целевым путём
Целевой путь принадлежит **одной** загрузке. Когда новая раскладка
успешно ложится на путь, который раньше занимала другая загрузка (путь к
этому моменту **свободен** — иначе была бы коллизия → review, чужой файл
не перезаписываем), владение переходит к новой загрузке: прежние записи
`file_link` на этот путь помечаются статусом `superseded` и перестают
считаться целью прежней загрузки. Это нужно сверке с реальностью: иначе
повторная закачка того же фильма (например, в другом качестве) по тому же
пути ложно «воскрешала» бы удалённую задачу — см.
[workflow.md](workflow.md) → «Сверка с реальностью». `superseded`-ссылки
не считаются целью при сверке и не снимаются в `Undo`.
Желательно: целевой и исходный каталоги — на одной ФС/одном mount'е
(внутри контейнера это обеспечивает единая песочница `/srv/media`), тогда
работает дешёвый хардлинк. Если хардлинк невозможен (разные ФС или ФС без
+12 -5
View File
@@ -107,12 +107,19 @@ stateDiagram-v2
«Привязать заново» (`→ recognizing`); авто-действий нет.
- **orphaned** — источник пропал, цель (последняя копия данных) на месте.
Команд вперёд нет; `Undo` запрещён (снял бы единственную копию).
- **deleted** — нет ни источника, ни цели; терминально.
- **deleted** — нет ни источника, ни цели; **терминально**: сверка его
больше не переоценивает (см. ниже).
Сверка трогает только `done`/`target_missing`/`orphaned`/`deleted`
активные и пользовательски-терминальные (`reverted`/`cancelled`/`failed`/
`stuck`) состояния не задевает. Реальность «лечится» сама: при возврате
источника/цели задача переходит обратно (вплоть до `done`). Пропажа
Сверка трогает только `done`/`target_missing`/`orphaned` — терминальный
`deleted`, активные и пользовательски-терминальные (`reverted`/`cancelled`/
`failed`/`stuck`) состояния не задевает. Реальность «лечится» сама: при
возврате источника/цели задача переходит обратно (вплоть до `done`) — но
**не из `deleted`**: к терминальной задаче источник не вернётся
(идемпотентность снимается только для активных), а её бывший целевой путь, если
его заняла другая загрузка, отбирается переходом владения (см.
[jellyfin-layout.md](jellyfin-layout.md) → «Владение целевым путём»).
Без этого правила переиспользование пути ложно «воскрешало» бы удалённую
задачу в `orphaned`. Пропажа
**источника** дебаунсится (`[worker].source_missing_threshold` подряд идущих
тиков), пропажа цели проверяется немедленно (локальная ФС надёжна). Команды,
которым нужен источник (relink/распознать/применить/undo), проверяют его
+2
View File
@@ -67,6 +67,7 @@ type candidateView struct {
ProviderID string
Title string
Year int
URL string
Chosen bool
}
@@ -130,6 +131,7 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
ProviderID: c.ProviderID,
Title: c.Title.String,
Year: int(c.Year.Int64),
URL: c.URL.String,
Chosen: c.Chosen,
})
}
+1
View File
@@ -217,6 +217,7 @@ const (
StatusCopied LinkStatus = "copied" // хардлинк невозможен — файл скопирован (фолбэк)
StatusExists LinkStatus = "exists" // уже была (тот же inode) — идемпотентно
StatusCollision LinkStatus = "collision" // цель занята другим файлом
StatusSuperseded LinkStatus = "superseded" // путь перехватила другая загрузка (см. state-reconciliation, владение путём)
)
// Result — итог по одной ссылке.
+1
View File
@@ -36,6 +36,7 @@ type Candidate struct {
Title string
OriginalTitle string
Year int
URL string // ссылка на страницу элемента на сайте провайдера
TagProvider string // напр. "tvdb"/"imdb" (опц.)
TagID string
}
+5
View File
@@ -98,6 +98,10 @@ func (t *TMDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
return nil, fmt.Errorf("tmdb search: %w", err)
}
webPath := "movie"
if q.Type == Series {
webPath = "tv"
}
out := make([]Candidate, 0, len(resp.Results))
for _, r := range resp.Results {
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,
OriginalTitle: orig,
Year: yearOf(date),
URL: "https://www.themoviedb.org/" + webPath + "/" + strconv.Itoa(r.ID),
})
}
return out, nil
+6
View File
@@ -42,6 +42,9 @@ func TestTMDB_SearchMovie(t *testing.T) {
if c.Provider != "tmdb" || c.ID != "603" || c.Title != "The Matrix" || c.Year != 1999 {
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) {
@@ -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 {
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) {
+1
View File
@@ -176,6 +176,7 @@ func (t *TVDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
ID: r.TVDBID,
Title: r.Name,
Year: year,
URL: "https://www.thetvdb.com/dereferrer/series/" + r.TVDBID,
})
}
return out, nil
+3
View File
@@ -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 {
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 {
t.Fatal(err)
+1
View File
@@ -82,6 +82,7 @@ func (t *TVMaze) Search(ctx context.Context, q Query) ([]Candidate, error) {
ID: strconv.Itoa(s.ID),
Title: s.Name,
Year: yearOf(s.Premiered),
URL: "https://www.tvmaze.com/shows/" + strconv.Itoa(s.ID),
}
// Тег папки — привычный TVDB-id, если есть; иначе IMDb.
switch {
+3
View File
@@ -41,6 +41,9 @@ func TestTVMaze_SearchSeries(t *testing.T) {
if c.Provider != "tvmaze" || c.ID != "1" || c.Title != "Fargo" || c.Year != 2014 {
t.Errorf("candidate = %+v", c)
}
if c.URL != "https://www.tvmaze.com/shows/1" {
t.Errorf("URL = %q", c.URL)
}
// TVDB-id из externals → тег папки.
if c.TagProvider != "tvdb" || c.TagID != "269613" {
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;
+33 -3
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
)
// Recognition — строка таблицы recognition (попытка распознавания).
@@ -195,6 +196,34 @@ VALUES (?, ?, ?, ?, ?, ?)`
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 последнего применённого батча
// загрузки (для undo) либо пустую строку, если ссылок нет.
func (s *Store) LatestBatchID(ctx context.Context, downloadID int64) (string, error) {
@@ -242,6 +271,7 @@ type MetadataCandidate struct {
ProviderID string `db:"provider_id"`
Title sql.NullString `db:"title"`
Year sql.NullInt64 `db:"year"`
URL sql.NullString `db:"url"`
Chosen bool `db:"chosen"`
CreatedAt string `db:"created_at"`
}
@@ -258,11 +288,11 @@ func (s *Store) CreateCandidates(ctx context.Context, cands []MetadataCandidate)
defer func() { _ = tx.Rollback() }()
const q = `
INSERT INTO metadata_candidate (recognition_id, provider, provider_id, title, year)
VALUES (?, ?, ?, ?, ?)`
INSERT INTO metadata_candidate (recognition_id, provider, provider_id, title, year, url)
VALUES (?, ?, ?, ?, ?, ?)`
for _, c := range cands {
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)
}
}
+50
View File
@@ -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) {
st := newTestStore(t)
ctx := context.Background()
+5 -3
View File
@@ -14,14 +14,16 @@ import (
)
// desyncStates — состояния, которые ведёт сверка с реальностью: уже
// разложенные (done) и сами состояния рассинхрона. Активные и
// разложенные (done) и восстановимые состояния рассинхрона. deleted сюда не
// входит — оно терминально и сверкой не переоценивается (источник к
// терминальной задаче не вернётся из-за идемпотентности, а цель отбирается
// переходом владения путём; см. state-reconciliation). Активные и
// пользовательски-терминальные (reverted/cancelled/failed/stuck) сверка не
// трогает (см. state-reconciliation).
// трогает.
var desyncStates = []store.State{
store.StateDone,
store.StateTargetMissing,
store.StateOrphaned,
store.StateDeleted,
}
// deriveState выводит состояние задачи из двумерной матрицы «источник × цель»
+12
View File
@@ -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) {
// Порог 3: первые два промаха не помечают, третий — помечает orphaned;
// возврат источника лечит обратно и сбрасывает счётчик.
+28 -3
View File
@@ -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 {
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 {
@@ -494,9 +510,15 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
if err != nil {
return fmt.Errorf("undo: %w", err)
}
links := make([]layout.Link, len(rows))
for i, r := range rows {
links[i] = layout.Link{Src: r.SrcPath, Dst: r.DstPath, Kind: layout.Kind(r.Kind)}
// Снимаем только реально разложенные нами ссылки. superseded-строки —
// путь забрала другая загрузка (см. state-reconciliation, владение
// путём); файл по нему теперь её хардлинк, трогать его нельзя.
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)
if err != nil {
@@ -763,6 +785,9 @@ func toStoreCandidates(recognitionID int64, cands []metadata.Candidate) []store.
if c.Year != 0 {
mc.Year = sql.NullInt64{Int64: int64(c.Year), Valid: true}
}
if c.URL != "" {
mc.URL = store.NullString(c.URL)
}
out = append(out, mc)
}
return out
+112 -1
View File
@@ -353,6 +353,25 @@ func (m *memStore) CreateFileLinks(_ context.Context, links []store.FileLink) er
m.links = append(m.links, links...)
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) {
for i := len(m.links) - 1; i >= 0; i-- {
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) {
plan := seriesResult().Plan
f := newApplyFixture(t, plan)
@@ -941,7 +1026,7 @@ func TestRecognizeOne_PersistsCandidates(t *testing.T) {
}
res := seriesResult()
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},
}
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" {
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) {
@@ -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) {
s, e := 1, 3
plan := recognize.Plan{
+1
View File
@@ -55,6 +55,7 @@ type Store interface {
SetOverride(ctx context.Context, downloadID int64, field, value string) error
ListOverrides(ctx context.Context, downloadID int64) (map[string]string, 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)
ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, error)
DeleteFileLinksByBatch(ctx context.Context, batchID string) error
+3
View File
@@ -114,6 +114,9 @@ func (f *fakeStore) ListOverrides(_ context.Context, _ int64) (map[string]string
return nil, nil
}
func (f *fakeStore) CreateFileLinks(_ context.Context, _ []store.FileLink) 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) {
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` по уже удалённым загрузкам; повторная закачка того же
фильма в другом качестве больше не «трогает» прежнюю задачу.
@@ -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` проходит.
+50
View File
@@ -92,3 +92,53 @@ gracefully использует доступные названия.
- **GIVEN** план с названием «Тёмный рыцарь» и кандидат базы «Темный рыцарь»
- **WHEN** сравниваются нормализованные названия
- **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 доступен без повторной
генерации
+67 -8
View File
@@ -18,12 +18,15 @@ qBittorrent. Capability описывает периодическую и при
ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и
выводить состояние задачи из двух независимых признаков: присутствия
**источника** (раздача с `download.infohash` в выдаче qBittorrent) и
присутствия **цели** (все `file_link` со `status = linked` существуют на ФС).
присутствия **цели** (см. требование о владении целевым путём: существуют все
ссылки последнего батча со статусом раскладки, всё ещё принадлежащие этой
загрузке).
Сверке SHALL подвергаться только состояния `done`, `target_missing`,
`orphaned`, `deleted`. Активные (`downloading`/`recognizing`/`review`/
`deferred`/`linking`) и пользовательски-терминальные (`reverted`/`cancelled`/
`failed`/`stuck`) состояния сверка трогать SHALL NOT.
`orphaned`. Состояние `deleted` сверка трогать SHALL NOT — оно терминально.
Активные (`downloading`/`recognizing`/`review`/`deferred`/`linking`) и
пользовательски-терминальные (`reverted`/`cancelled`/`failed`/`stuck`)
состояния сверка трогать SHALL NOT.
Состояние SHALL переписываться только при его изменении (без записи и логов,
когда выведенное состояние совпадает с текущим).
@@ -40,6 +43,12 @@ qBittorrent. Capability описывает периодическую и при
- **WHEN** часть разложенных хардлинков задачи удалена, а источник на месте
- **THEN** цель считается отсутствующей и задача переходит в `target_missing`
#### Scenario: Задача в deleted сверкой не переоценивается
- **WHEN** задача находится в `deleted`
- **THEN** сверка её не рассматривает и состояние не меняет, даже если по её
бывшему пути появился файл другой загрузки
### Requirement: Принудительная проверка источника/цели перед действием
Команда workflow, требующая наличия источника или цели, SHALL синхронно
@@ -117,8 +126,10 @@ NOT полагаться только на фоновую сверку `worker`
### Requirement: Состояние deleted при пропаже источника и цели
Когда отсутствуют и источник (с учётом дебаунса), и цель, система SHALL
переводить задачу в состояние `deleted`. В `deleted` действий над задачей
больше нет.
переводить задачу в состояние `deleted`. `deleted` терминально: действий над
задачей больше нет, и сверка её больше не переоценивает (источник к
терминальной задаче не возвращается из-за идемпотентности, а цель отбирается
переходом владения путём к другой загрузке).
#### Scenario: Источник и цель удалены
@@ -126,6 +137,54 @@ NOT полагаться только на фоновую сверку `worker`
хардлинков задачи на ФС больше нет
- **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: Дебаунс пропажи источника
Система SHALL дебаунсить только **отсутствие источника**, чтобы временная
@@ -154,8 +213,8 @@ SHALL сбрасывать счётчик пропусков.
Система SHALL возвращать задачу в согласованное состояние, когда реальность
восстановилась (состояние выводится из текущей матрицы «источник × цель»):
при возврате источника и/или цели задача SHALL переходить из
`orphaned`/`target_missing`/`deleted` обратно (в т.ч. в `done`, когда
присутствуют оба).
`orphaned`/`target_missing` обратно (в т.ч. в `done`, когда присутствуют
оба). Из терминального `deleted` самовосстановления SHALL NOT быть.
#### Scenario: Источник вернулся
+2 -1
View File
@@ -75,7 +75,7 @@
{{if .Candidates}}
<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>
{{range .Candidates}}
<tr>
@@ -83,6 +83,7 @@
<td>{{.Title}}</td>
<td>{{if .Year}}{{.Year}}{{end}}</td>
<td class="src">{{.ProviderID}}</td>
<td>{{if .URL}}<a href="{{.URL}}" target="_blank" rel="noopener">{{.Provider}}</a>{{end}}</td>
<td>
<form method="post" action="/ui/downloads/{{$.ID}}/candidate">
<input type="hidden" name="candidate_id" value="{{.ID}}">