- Список: серверные фильтр по группе состояний, поиск и пагинация (GET
f/q/page/all, по 25), сортировка по времени добавления в qBittorrent
(added_on) с фолбеком на created_at и tie-break по id.
- Заголовок загрузки = имя раздачи (display_name) → распознанное название →
усечённый источник; сырой magnet вынесен в блок «Информация о торренте».
- Матч метабазы показан ссылкой на запись (страница загрузки и ревью);
URL берётся у выбранного кандидата либо строится по provider+id и типу.
- Полировка вёрстки; клиентская JS-фильтрация убрана (всё серверное, без JS).
- Миграция 0005 (display_name, source_added_at); воркер однократно
фиксирует source_added_at при поллинге/усыновлении; ER-схема обновлена.
- OpenSpec: дельты влиты в specs/{web-ui,ingest}, change заархивирован.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
104 lines
4.1 KiB
Go
104 lines
4.1 KiB
Go
package worker
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
|
)
|
|
|
|
// discover усыновляет новые раздачи: для каждого торрента с нашей категорией
|
|
// ИЛИ тегом, чьего infohash ещё нет в БД, заводит задачу downloading. Дальше
|
|
// её ведёт обычный reconcile. Вызывается под w.mu.
|
|
//
|
|
// Корректность при гонке с Ingest (другая горутина): Ingest пишет строку в
|
|
// БД до добавления в qBit и ставит idempotency_key=infohash, на который есть
|
|
// UNIQUE-индекс. Поэтому даже если тик и Ingest столкнутся в окне «проверил →
|
|
// вставляю», второй INSERT упадёт на индексе, и adopt просто пропустит.
|
|
func (w *Worker) discover(ctx context.Context, torrents []qbt.Torrent) {
|
|
for _, t := range torrents {
|
|
if w.tracked(t) {
|
|
w.adopt(ctx, t)
|
|
}
|
|
}
|
|
}
|
|
|
|
// tracked — относится ли торрент к jellybit (категория или тег из конфига).
|
|
func (w *Worker) tracked(t qbt.Torrent) bool {
|
|
if w.cfg.Category != "" && t.Category == w.cfg.Category {
|
|
return true
|
|
}
|
|
return hasTag(t.Tags, w.cfg.Tag)
|
|
}
|
|
|
|
// adopt заводит задачу под торрент, если его ещё не видели.
|
|
func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
|
|
infohash := firstInfohash(t)
|
|
if infohash == "" {
|
|
return // нечем идентифицировать (напр. ещё metaDL без хэша)
|
|
}
|
|
exists, err := w.store.ExistsByInfohash(ctx, infohash)
|
|
if err != nil {
|
|
w.log.Warn("discover exists check failed", "capability", capIngest, "infohash", infohash, "error", err)
|
|
return
|
|
}
|
|
if exists {
|
|
return // уже усыновлён ранее (или обработан) — не трогаем
|
|
}
|
|
|
|
d := &store.Download{
|
|
SourceType: store.SourceMagnet,
|
|
SourceRef: "magnet:?xt=urn:btih:" + infohash,
|
|
DisplayName: t.Name, // усыновление: приёма/rename нет, берём имя торрента из qBittorrent
|
|
Infohash: store.NullString(infohash),
|
|
IdempotencyKey: store.NullString(infohash),
|
|
State: store.StateDownloading,
|
|
}
|
|
id, err := w.store.CreateDownload(ctx, d)
|
|
if err != nil {
|
|
// Гонка: Ingest/другой тик мог вставить запись между проверкой и
|
|
// вставкой — UNIQUE-индекс это отсёк. Если запись появилась, всё ок.
|
|
if ex, _ := w.store.ExistsByInfohash(ctx, infohash); ex {
|
|
return
|
|
}
|
|
w.log.Error("discover adopt failed", "capability", capIngest, "infohash", infohash, "error", err)
|
|
return
|
|
}
|
|
// Базис сортировки — время добавления в источник; у усыновлённого оно уже
|
|
// известно (created_at задачи было бы моментом усыновления, не добавления).
|
|
if t.AddedOn > 0 {
|
|
if err := w.store.SetSourceAddedAt(ctx, id, time.Unix(t.AddedOn, 0)); err != nil {
|
|
w.log.Warn("adopt set source_added_at failed",
|
|
"capability", capIngest, "download_id", id, "error", err)
|
|
}
|
|
}
|
|
w.log.Info("discover adopted torrent",
|
|
"capability", capIngest, "download_id", id, "infohash", infohash, "name", t.Name,
|
|
"category", t.Category, "tags", t.Tags)
|
|
}
|
|
|
|
// hasTag сообщает, есть ли tag среди списка тегов qBit (через запятую).
|
|
func hasTag(tags, tag string) bool {
|
|
if tag == "" {
|
|
return false
|
|
}
|
|
for _, x := range strings.Split(tags, ",") {
|
|
if strings.TrimSpace(x) == tag {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// firstInfohash возвращает первый непустой infohash торрента (нижний регистр).
|
|
func firstInfohash(t qbt.Torrent) string {
|
|
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
|
|
if h != "" {
|
|
return strings.ToLower(h)
|
|
}
|
|
}
|
|
return ""
|
|
}
|