Идентичность на ULID: download_infohash, guarded-дедуп, миграция (ulid-identity)
Все сущности переехали с INTEGER AUTOINCREMENT на TEXT ULID (lowercase, internal/ident — единая точка генерации и разбора; oklog/ulid). Инфохэши загрузки — множество (download_infohash, v1/v2 гибридных торрентов): дедуп и сопоставление в поллинге по любому из хешей, magnet-парсер отдаёт оба хеша гибридной ссылки, усечённый v2-хеш v2-only раздач не хранится. Инвариант «не более одной активной загрузки на infohash» вместо снятого unique-индекса держат guarded-методы store в одной write-транзакции (_txlock=immediate): CreateDownloadIfNoActive (приём/adopt, с доносом недостающих хешей), ActivateIfNoOtherActive (retry/recovery/relink, отказ до побочных эффектов), guarded AddInfohashes; SetDownloadState отклоняет терминал→активное как механический бэкстоп. Миграция 0006 — первая Go-миграция goose: пересоздание таблиц при включённых FK, backfill ULID с timestamp из created_at (хронология id сохранена), разнос infohash, удаление idempotency_key. BREAKING: формат id в URL/логах/Telegram, REST-поля id (string) и infohashes (список). Новая конвенция docs/conventions/database.md (без числовых PK), корреляция в логах grep'ом по голому ULID, ER-схема обновлена. Спеки: новая capability identity, MODIFIED в state-reconciliation; change заархивирован. Пройдены ревью дизайна и кода (по 8 углов), все находки исправлены с регрессионными тестами. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+64
-60
@@ -50,7 +50,7 @@ func (w *Worker) recognizePending(ctx context.Context) {
|
||||
// под блокировкой переводим в recognizing, LLM зовём без блокировки, затем
|
||||
// под блокировкой фиксируем результат — но только если задачу за это время
|
||||
// не увели в другое состояние (cancel/defer).
|
||||
func (w *Worker) recognizeOne(ctx context.Context, id int64) {
|
||||
func (w *Worker) recognizeOne(ctx context.Context, id string) {
|
||||
w.mu.Lock()
|
||||
d, err := w.store.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
@@ -62,7 +62,7 @@ func (w *Worker) recognizeOne(ctx context.Context, id int64) {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
ctx = w.scoped(ctx, capRecognize, id, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capRecognize, id, d.PrimaryInfohash())
|
||||
if d.State == store.StateCompleted {
|
||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
||||
}
|
||||
@@ -84,10 +84,10 @@ func (w *Worker) recognizeOne(ctx context.Context, id int64) {
|
||||
// затем зовёт распознаватель. Возвращает также savePath для маппинга
|
||||
// относительных путей файлов в абсолютные при раскладке.
|
||||
func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.Result, string, error) {
|
||||
if !d.Infohash.Valid {
|
||||
if len(d.Infohashes) == 0 {
|
||||
return recognize.Result{}, "", fmt.Errorf("no infohash")
|
||||
}
|
||||
t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
|
||||
t, ok, err := w.torrentByInfohash(ctx, d.HashList())
|
||||
if err != nil {
|
||||
return recognize.Result{}, "", err
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.
|
||||
|
||||
// finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3
|
||||
// метабазы выключены → авто-раскладки не делаем, всегда уходим в review.
|
||||
func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.Result, savePath string) {
|
||||
func (w *Worker) finishRecognition(ctx context.Context, id string, res recognize.Result, savePath string) {
|
||||
log := logctx.From(ctx)
|
||||
planJSON, err := json.Marshal(res.Plan)
|
||||
if err != nil {
|
||||
@@ -174,6 +174,9 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
|
||||
log.Error("recognition persist failed", "error", err)
|
||||
return
|
||||
}
|
||||
// recognition_id — ключ корреляции попытки (грепается и голым ULID).
|
||||
log.Info("recognition persisted", "recognition_id", recID,
|
||||
"provider", provider, "provider_id", providerID)
|
||||
// Кандидаты базы — для ручного выбора в review.
|
||||
if cands := toStoreCandidates(recID, res.Candidates); len(cands) > 0 {
|
||||
if err := w.store.CreateCandidates(ctx, cands); err != nil {
|
||||
@@ -189,7 +192,7 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
|
||||
forceReview := overrides[ovrForceReview] == "1"
|
||||
if res.Decision.Auto && !forceReview && w.layouter != nil {
|
||||
plan := applyOverrides(res.Plan, overrides)
|
||||
lctx := w.scoped(ctx, capFileLayout, id, d.Infohash.String)
|
||||
lctx := w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
|
||||
w.transition(lctx, *d, store.StateLinking, "", "")
|
||||
if err := w.linkPlan(lctx, d, plan, tag, savePath); err != nil {
|
||||
logctx.From(lctx).Warn("auto-apply failed, left for review", "error", err)
|
||||
@@ -200,7 +203,7 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
|
||||
}
|
||||
|
||||
// overridesOrNil читает правки, проглатывая ошибку (для авто-пути).
|
||||
func (w *Worker) overridesOrNil(ctx context.Context, id int64) map[string]string {
|
||||
func (w *Worker) overridesOrNil(ctx context.Context, id string) map[string]string {
|
||||
o, err := w.store.ListOverrides(ctx, id)
|
||||
if err != nil {
|
||||
logctx.From(ctx).Warn("recognition list overrides failed", "error", err)
|
||||
@@ -213,7 +216,7 @@ func (w *Worker) overridesOrNil(ctx context.Context, id int64) map[string]string
|
||||
|
||||
// Apply создаёт хардлинки по текущему плану (с применёнными правками) и
|
||||
// переводит задачу в done. Коллизия цели → остаёмся в review с причиной.
|
||||
func (w *Worker) Apply(ctx context.Context, id int64) error {
|
||||
func (w *Worker) Apply(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.layouter == nil {
|
||||
@@ -225,15 +228,15 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
|
||||
return fmt.Errorf("apply: %w", err)
|
||||
}
|
||||
if d.State != store.StateReview && d.State != store.StateDeferred {
|
||||
return fmt.Errorf("apply: download %d is in state %s (expected review/deferred): %w", id, d.State, ErrConflict)
|
||||
return fmt.Errorf("apply: download %s is in state %s (expected review/deferred): %w", id, d.State, ErrConflict)
|
||||
}
|
||||
ctx = w.scoped(ctx, capFileLayout, id, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
|
||||
|
||||
plan, tag, err := w.effectivePlan(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply: %w", err)
|
||||
}
|
||||
t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
|
||||
t, ok, err := w.torrentByInfohash(ctx, d.HashList())
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply: lookup torrent: %w", err)
|
||||
}
|
||||
@@ -308,7 +311,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
||||
}
|
||||
|
||||
w.transition(ctx, *d, store.StateDone, "", "")
|
||||
logctx.From(ctx).Info("layout linked", "batch", batch, "links", len(fl))
|
||||
logctx.From(ctx).Info("layout linked", "batch_id", batch, "links", len(fl))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -317,7 +320,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
||||
// перезапустит recognize. Авто-раскладку при этом не делаем — ручная
|
||||
// перепривязка всегда проходит через ревью с подтверждением (force_review).
|
||||
// Источник (раздача в qBittorrent) для этого должен быть на месте.
|
||||
func (w *Worker) Relink(ctx context.Context, id int64) error {
|
||||
func (w *Worker) Relink(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -326,28 +329,26 @@ func (w *Worker) Relink(ctx context.Context, id int64) error {
|
||||
return fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
if d.State != store.StateReverted && d.State != store.StateCancelled && d.State != store.StateTargetMissing {
|
||||
return fmt.Errorf("relink: download %d is in state %s (expected reverted/cancelled/target_missing): %w", id, d.State, ErrConflict)
|
||||
return fmt.Errorf("relink: download %s is in state %s (expected reverted/cancelled/target_missing): %w", id, d.State, ErrConflict)
|
||||
}
|
||||
// Источник нужен для распознавания — проверяем синхронно (без дебаунса) и при
|
||||
// его отсутствии приводим состояние к реальности (orphaned/deleted).
|
||||
if err := w.ensureSourcePresent(ctx, d, "relink"); err != nil {
|
||||
return err
|
||||
}
|
||||
// Вернуть задачу в активную обработку можно, только если другой активной
|
||||
// задачи на этот infohash нет (partial unique index по idempotency_key).
|
||||
active, err := w.store.FindActiveByInfohash(ctx, d.Infohash.String)
|
||||
if err != nil {
|
||||
return fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
if active != nil {
|
||||
return fmt.Errorf("relink: для этого торрента уже есть активная задача #%d", active.ID)
|
||||
}
|
||||
// Ручная перепривязка — всегда с подтверждением, без авто-раскладки.
|
||||
if err := w.store.SetOverride(ctx, id, ovrForceReview, "1"); err != nil {
|
||||
return fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
|
||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
// Возврат в активную обработку — только через атомарный гард инварианта
|
||||
// «не более одной активной задачи на infohash» (см. design ulid-identity, D4).
|
||||
if err := w.store.ActivateIfNoOtherActive(ctx, id, store.StateRecognizing, "", ""); err != nil {
|
||||
if errors.Is(err, store.ErrInfohashTaken) {
|
||||
return fmt.Errorf("relink: для этого торрента уже есть активная задача: %w", ErrConflict)
|
||||
}
|
||||
return fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
logctx.From(ctx).Info("relink re-recognizing", "from", d.State)
|
||||
return nil
|
||||
}
|
||||
@@ -355,7 +356,7 @@ func (w *Worker) Relink(ctx context.Context, id int64) error {
|
||||
// Rerecognize перезапускает распознавание для задачи в review/deferred без
|
||||
// добавления подсказки: контекст и прежние подсказки уже накоплены. Поллинг-
|
||||
// цикл проведёт задачу recognizing → review заново.
|
||||
func (w *Worker) Rerecognize(ctx context.Context, id int64) error {
|
||||
func (w *Worker) Rerecognize(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -366,14 +367,14 @@ func (w *Worker) Rerecognize(ctx context.Context, id int64) error {
|
||||
if err := w.ensureSourcePresent(ctx, d, "rerecognize"); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
logctx.From(ctx).Info("review re-recognizing without hint")
|
||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Refine добавляет подсказку и отправляет задачу на перераспознавание.
|
||||
func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
|
||||
func (w *Worker) Refine(ctx context.Context, id string, hint string) error {
|
||||
hint = strings.TrimSpace(hint)
|
||||
if hint == "" {
|
||||
return fmt.Errorf("refine: empty hint")
|
||||
@@ -388,7 +389,7 @@ func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
|
||||
if err := w.ensureSourcePresent(ctx, d, "refine"); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
if err := w.store.AddHint(ctx, id, hint); err != nil {
|
||||
return fmt.Errorf("refine: %w", err)
|
||||
}
|
||||
@@ -399,7 +400,7 @@ func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
|
||||
|
||||
// SetType фиксирует тип (override) и перезапускает распознавание с подсказкой
|
||||
// — чтобы LLM пересобрал роли файлов под новый тип.
|
||||
func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error {
|
||||
func (w *Worker) SetType(ctx context.Context, id string, mediaType string) error {
|
||||
if mediaType != string(recognize.MediaMovie) && mediaType != string(recognize.MediaSeries) {
|
||||
return fmt.Errorf("set type: invalid type %q", mediaType)
|
||||
}
|
||||
@@ -413,7 +414,7 @@ func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error
|
||||
if err := w.ensureSourcePresent(ctx, d, "set type"); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
if err := w.store.SetOverride(ctx, id, ovrMediaType, mediaType); err != nil {
|
||||
return fmt.Errorf("set type: %w", err)
|
||||
}
|
||||
@@ -430,7 +431,7 @@ func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error
|
||||
|
||||
// IgnoreFile помечает файл к игнорированию (не линкуем). Остаёмся в review;
|
||||
// превью пересчитается с учётом правки.
|
||||
func (w *Worker) IgnoreFile(ctx context.Context, id int64, src string) error {
|
||||
func (w *Worker) IgnoreFile(ctx context.Context, id string, src string) error {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return fmt.Errorf("ignore: empty path")
|
||||
@@ -454,12 +455,12 @@ func (w *Worker) IgnoreFile(ctx context.Context, id int64, src string) error {
|
||||
if err := w.store.SetOverride(ctx, id, ovrIgnoredFiles, string(b)); err != nil {
|
||||
return fmt.Errorf("ignore: %w", err)
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review file ignored", "src", src)
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review file ignored", "src", src)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Defer паркует задачу в deferred (вернётся в ревью по действию).
|
||||
func (w *Worker) Defer(ctx context.Context, id int64) error {
|
||||
func (w *Worker) Defer(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -468,9 +469,9 @@ func (w *Worker) Defer(ctx context.Context, id int64) error {
|
||||
return fmt.Errorf("defer: %w", err)
|
||||
}
|
||||
if d.State.IsTerminal() {
|
||||
return fmt.Errorf("defer: download %d is terminal (%s)", id, d.State)
|
||||
return fmt.Errorf("defer: download %s is terminal (%s)", id, d.State)
|
||||
}
|
||||
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
w.transition(ctx, *d, store.StateDeferred, "", "")
|
||||
return nil
|
||||
}
|
||||
@@ -479,7 +480,7 @@ func (w *Worker) Defer(ctx context.Context, id int64) error {
|
||||
// Источник недосягаем (раскладчик удаляет только пути под библиотекой). Откат
|
||||
// снимает ЛИШНИЙ хардлинк, а не последнюю копию: layout.Undo отказывается
|
||||
// удалять ссылку, если источник уже пропал (nlink<=1) — см. state-reconciliation.
|
||||
func (w *Worker) Undo(ctx context.Context, id int64) error {
|
||||
func (w *Worker) Undo(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.layouter == nil {
|
||||
@@ -496,9 +497,9 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
|
||||
return fmt.Errorf("undo: источник удалён, цель — последняя копия данных, откат невозможен: %w", ErrConflict)
|
||||
}
|
||||
if d.State != store.StateDone {
|
||||
return fmt.Errorf("undo: download %d is in state %s (expected done): %w", id, d.State, ErrConflict)
|
||||
return fmt.Errorf("undo: download %s is in state %s (expected done): %w", id, d.State, ErrConflict)
|
||||
}
|
||||
ctx = w.scoped(ctx, capFileLayout, id, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
|
||||
batch, err := w.store.LatestBatchID(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("undo: %w", err)
|
||||
@@ -528,18 +529,18 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
|
||||
return fmt.Errorf("undo: %w", err)
|
||||
}
|
||||
w.transition(ctx, *d, store.StateReverted, "", "")
|
||||
logctx.From(ctx).Info("layout reverted", "batch", batch, "removed", n)
|
||||
logctx.From(ctx).Info("layout reverted", "batch_id", batch, "removed", n)
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireReviewable проверяет, что задача в review/deferred. Вызывается под mu.
|
||||
func (w *Worker) requireReviewable(ctx context.Context, id int64, op string) (*store.Download, error) {
|
||||
func (w *Worker) requireReviewable(ctx context.Context, id string, op string) (*store.Download, error) {
|
||||
d, err := w.store.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", op, err)
|
||||
}
|
||||
if d.State != store.StateReview && d.State != store.StateDeferred {
|
||||
return nil, fmt.Errorf("%s: download %d is in state %s (expected review/deferred): %w", op, id, d.State, ErrConflict)
|
||||
return nil, fmt.Errorf("%s: download %s is in state %s (expected review/deferred): %w", op, id, d.State, ErrConflict)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -549,7 +550,7 @@ func (w *Worker) requireReviewable(ctx context.Context, id int64, op string) (*s
|
||||
// ChooseCandidate пиннит выбранного кандидата базы как override (провайдер,
|
||||
// id, каноническое имя/год). Раскладку не запускает — превью обновится, а
|
||||
// человек подтвердит «Применить».
|
||||
func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID int64) error {
|
||||
func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -566,7 +567,7 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID int64) err
|
||||
return fmt.Errorf("choose candidate: %w", err)
|
||||
}
|
||||
if rec == nil || cand == nil || cand.RecognitionID != rec.ID {
|
||||
return fmt.Errorf("choose candidate: candidate %d does not belong to the current recognition", candidateID)
|
||||
return fmt.Errorf("choose candidate: candidate %s does not belong to the current recognition", candidateID)
|
||||
}
|
||||
|
||||
pins := map[string]string{ovrProvider: cand.Provider, ovrProviderID: cand.ProviderID}
|
||||
@@ -584,13 +585,13 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID int64) err
|
||||
if err := w.store.SetCandidateChosen(ctx, rec.ID, candidateID); err != nil {
|
||||
return fmt.Errorf("choose candidate: %w", err)
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review candidate chosen",
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review candidate chosen",
|
||||
"provider", cand.Provider, "provider_id", cand.ProviderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetProviderID пиннит провайдера и id вручную (без выбора из списка).
|
||||
func (w *Worker) SetProviderID(ctx context.Context, id int64, provider, providerID string) error {
|
||||
func (w *Worker) SetProviderID(ctx context.Context, id string, provider, providerID string) error {
|
||||
provider = strings.TrimSpace(strings.ToLower(provider))
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
switch provider {
|
||||
@@ -614,13 +615,13 @@ func (w *Worker) SetProviderID(ctx context.Context, id int64, provider, provider
|
||||
if err := w.store.SetOverride(ctx, id, ovrProviderID, providerID); err != nil {
|
||||
return fmt.Errorf("set provider: %w", err)
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review provider set",
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review provider set",
|
||||
"provider", provider, "provider_id", providerID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearProvider — «без базы»: снимает матч (тег папки не ставится).
|
||||
func (w *Worker) ClearProvider(ctx context.Context, id int64) error {
|
||||
func (w *Worker) ClearProvider(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -634,7 +635,7 @@ func (w *Worker) ClearProvider(ctx context.Context, id int64) error {
|
||||
if err := w.store.SetOverride(ctx, id, ovrProviderID, ""); err != nil {
|
||||
return fmt.Errorf("clear provider: %w", err)
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review provider cleared")
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review provider cleared")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -654,12 +655,12 @@ type ReviewData struct {
|
||||
}
|
||||
|
||||
// ReviewData собирает данные ревью по загрузке.
|
||||
func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error) {
|
||||
func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error) {
|
||||
d, err := w.store.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("review data: %w", err)
|
||||
}
|
||||
log := logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String))
|
||||
log := logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash()))
|
||||
rec, err := w.store.GetCurrentRecognition(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("review data: %w", err)
|
||||
@@ -709,7 +710,7 @@ func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error)
|
||||
|
||||
// effectivePlan загружает текущий план, применяет правки и возвращает
|
||||
// provider-тег для имени папки (под mu).
|
||||
func (w *Worker) effectivePlan(ctx context.Context, id int64) (recognize.Plan, string, error) {
|
||||
func (w *Worker) effectivePlan(ctx context.Context, id string) (recognize.Plan, string, error) {
|
||||
rec, err := w.store.GetCurrentRecognition(ctx, id)
|
||||
if err != nil {
|
||||
return recognize.Plan{}, "", err
|
||||
@@ -772,7 +773,7 @@ func effectiveProvider(rec *store.Recognition, overrides map[string]string) (pro
|
||||
|
||||
// toStoreCandidates переводит кандидатов распознавания в строки БД,
|
||||
// подставляя тег-предпочтительный provider/id (внешний из TVMaze и т.п.).
|
||||
func toStoreCandidates(recognitionID int64, cands []metadata.Candidate) []store.MetadataCandidate {
|
||||
func toStoreCandidates(recognitionID string, cands []metadata.Candidate) []store.MetadataCandidate {
|
||||
out := make([]store.MetadataCandidate, 0, len(cands))
|
||||
for _, c := range cands {
|
||||
prov, id := recognize.CandidateTag(c)
|
||||
@@ -863,19 +864,22 @@ func mapRole(r recognize.FileRole) (layout.Role, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// torrentByInfohash ищет торрент по infohash (v1/v2/hash). Листаем ВСЕ
|
||||
// торренты (а не только свою категорию): раздача могла быть усыновлена по
|
||||
// тегу и иметь чужую/пустую категорию — фильтр по категории её бы потерял
|
||||
// (как и в Poll, см. там же).
|
||||
func (w *Worker) torrentByInfohash(ctx context.Context, infohash string) (qbt.Torrent, bool, error) {
|
||||
// torrentByInfohash ищет торрент по любому из хешей загрузки (v1/v2/hash).
|
||||
// Листаем ВСЕ торренты (а не только свою категорию): раздача могла быть
|
||||
// усыновлена по тегу и иметь чужую/пустую категорию — фильтр по категории её
|
||||
// бы потерял (как и в Poll, см. там же).
|
||||
func (w *Worker) torrentByInfohash(ctx context.Context, hashes []string) (qbt.Torrent, bool, error) {
|
||||
torrents, err := w.qbt.Torrents(ctx, "")
|
||||
if err != nil {
|
||||
return qbt.Torrent{}, false, err
|
||||
}
|
||||
want := strings.ToLower(infohash)
|
||||
want := make(map[string]bool, len(hashes))
|
||||
for _, h := range hashes {
|
||||
want[store.NormalizeHash(h)] = true
|
||||
}
|
||||
for _, t := range torrents {
|
||||
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
|
||||
if h != "" && strings.ToLower(h) == want {
|
||||
if h != "" && want[strings.ToLower(h)] {
|
||||
return t, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user