Files
jellybit/internal/worker/discover.go
T
avandClaude Opus 4.8 bcc7b2d76b Жизненный цикл: claim-токен распознавания, индексация хешей, retry сломанного торрента
Три мелких фикса из docs/backlog/review-lifecycle-minor.md (ревью Fable
2026-07-08). MINOR-9 (I/O под глобальным w.mu) осознанно waive для
one-user home-сервера — не трогаем.

MINOR-8: claim-токен распознавания. recognizeOne фиксирует updated_at на
момент claim (перечитывая запись после перехода в recognizing), а
finishRecognition коммитит результат, только если токен совпал. Иначе за
время LLM-вызова задачу увели из recognizing и вернули обратно
(cancel → relink revive) — это уже другой эпизод, устаревший результат
отбрасываем, задача остаётся в recognizing для перезапуска поллингом.

NIT-11: lookup-мапы (byHash/live/torrentByInfohash) больше не индексируют
усечённый 40-hex t.Hash v2-only торрентов. Новый хелпер torrentIndexHashes
зеркалит выбор torrentHashes: t.Hash берём только при отсутствии обоих
infohash_v1/v2. Убирает теоретический ложный матч по коллизии длины.

NIT-12: retry живого, но сломанного торрента (error/missingFiles) теперь
отклоняется с подсказкой починить раздачу (recheck) в qBittorrent, вместо
бессмысленной переотдачи источника (сверка тут же вернула бы задачу в
failed). Повторный Add — только когда раздачи в qBittorrent нет. Меняет
спеку state-reconciliation → дельта openspec/changes/2026-07-17-retry-reject-broken-torrent
(не архивировал).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 20:35:14 +03:00

146 lines
6.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package worker
import (
"context"
"slices"
"strings"
"time"
"git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/store"
)
// discover усыновляет новые раздачи: для каждого торрента с нашей категорией
// ИЛИ тегом, чьих хешей ещё нет в БД, заводит задачу downloading. Дальше
// её ведёт обычный reconcile. Вызывается под w.mu.
//
// Корректность при гонке с Ingest (другая горутина): и adopt, и приём идут
// через store.CreateDownloadIfNoActive — атомарный check-then-insert в одной
// write-транзакции; при столкновении второй участник получает существующую
// активную задачу и просто пропускает.
func (w *Worker) discover(ctx context.Context, torrents []qbt.Torrent) {
for _, t := range torrents {
if w.tracked(t) {
w.adopt(ctx, t)
}
}
}
// tracked — относится ли торрент к jellybit (категория или тег из конфига).
func (w *Worker) tracked(t qbt.Torrent) bool {
if w.cfg.Category != "" && t.Category == w.cfg.Category {
return true
}
return hasTag(t.Tags, w.cfg.Tag)
}
// adopt заводит задачу под торрент, если его ещё не видели.
func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
hashes := torrentHashes(t)
if len(hashes) == 0 {
return // нечем идентифицировать (напр. ещё metaDL без хэша)
}
exists, err := w.store.ExistsByInfohash(ctx, hashes...)
if err != nil {
w.log.Warn("discover exists check failed", "capability", capIngest, "infohash", hashes[0], "error", err)
return
}
if exists {
return // уже усыновлён ранее (или обработан) — не трогаем
}
d := &store.Download{
SourceType: store.SourceMagnet,
SourceRef: magnetURN(hashes[0]),
DisplayName: t.Name, // усыновление: приёма/rename нет, берём имя торрента из qBittorrent
State: store.StateDownloading,
}
existing, err := w.store.CreateDownloadIfNoActive(ctx, d, hashes, nil)
if err != nil {
w.log.Error("discover adopt failed", "capability", capIngest, "infohash", hashes[0], "error", err)
return
}
if existing != nil {
return // гонка с Ingest/другим тиком: задача уже заведена — всё ок
}
// Базис сортировки — время добавления в источник; у усыновлённого оно уже
// известно (created_at задачи было бы моментом усыновления, не добавления).
if t.AddedOn > 0 {
if err := w.store.SetSourceAddedAt(ctx, d.ID, time.Unix(t.AddedOn, 0)); err != nil {
w.log.Warn("adopt set source_added_at failed",
"capability", capIngest, "download_id", d.ID, "error", err)
}
}
w.log.Info("discover adopted torrent",
"capability", capIngest, "download_id", d.ID, "infohash", hashes[0], "name", t.Name,
"category", t.Category, "tags", t.Tags)
}
// hasTag сообщает, есть ли tag среди списка тегов qBit (через запятую).
func hasTag(tags, tag string) bool {
if tag == "" {
return false
}
for _, x := range strings.Split(tags, ",") {
if strings.TrimSpace(x) == tag {
return true
}
}
return false
}
// torrentHashes — все непустые хеши торрента (нижний регистр, без дублей,
// v1-приоритетный порядок: v1 раньше v2). Единственный сборщик хешей
// торрента для записи в БД: t.Hash берётся только когда qBittorrent не
// отдал infohash_v1/v2 (старые версии API); у v2-only раздачи t.Hash — это
// УСЕЧЁННЫЙ до 40 hex v2-хеш, хранить его нельзя (по длине он неотличим от
// v1 и порождает битые btih-magnet при retry).
func torrentHashes(t qbt.Torrent) []string {
cands := []string{t.InfohashV1, t.InfohashV2}
if t.InfohashV1 == "" && t.InfohashV2 == "" {
cands = append(cands, t.Hash)
}
var out []string
for _, h := range cands {
h = store.NormalizeHash(h)
if h != "" && !slices.Contains(out, h) {
out = append(out, h)
}
}
return out
}
// torrentIndexHashes — хеши, под которыми раздачу индексируют lookup-мапы
// (byHash/live) и поиск по infohash. Зеркалит выбор torrentHashes: t.Hash берём
// только когда qBittorrent не отдал infohash_v1/v2 (старый API). У v2-only
// раздачи t.Hash — усечённый до 40 hex v2-хеш; индексировать его нельзя — по длине
// он неотличим от v1 и мог бы ложно сматчить задачу с таким же v1-хешем (NIT-11),
// хотя для этого нужна невозможная на практике 160-битная коллизия. В отличие от
// torrentHashes хеши не нормализует и не дедуплицирует — ключ приводит вызывающий.
func torrentIndexHashes(t qbt.Torrent) []string {
if t.InfohashV1 == "" && t.InfohashV2 == "" {
if t.Hash == "" {
return nil
}
return []string{t.Hash}
}
out := make([]string, 0, 2)
if t.InfohashV1 != "" {
out = append(out, t.InfohashV1)
}
if t.InfohashV2 != "" {
out = append(out, t.InfohashV2)
}
return out
}
// magnetURN — синтетический источник усыновлённой раздачи по её хешу:
// btih для v1, btmh (multihash sha256, префикс 1220) для v2. Хеш обязан
// быть полноразмерным (torrentHashes усечённые не отдаёт).
func magnetURN(h string) string {
if store.HashKind(h) == store.HashV2 {
return "magnet:?xt=urn:btmh:1220" + h
}
return "magnet:?xt=urn:btih:" + h
}