Приём: добавление загрузки по .torrent-файлу
Принимаем .torrent как загруженные байты — через файл-пикер в веб-форме и Telegram-документ, наряду с magnet. Файл несёт полные метаданные: работает там, где magnet не резолвится (закрытые трекеры, без DHT), и даёт максимум контекста для распознавания без сети. - internal/torrent: парсер поверх anacrolix/torrent/metainfo — инфохэш(и) (v1 SHA1 исходных байтов info; v2 BEP52 при наличии) + Context() из имени, дерева файлов, размера, трекеров. Извлечение файлов панико-безопасно (недоверенный вход). - Персистентность байтов: таблица-спутник download_torrent (миграция 0009); пишется в транзакции создания загрузки, только на ветке создания (не при дедупе). Байты живут весь срок строки — нужны для повторного добавления при retry. - ingest: Request.TorrentData/TorrentName, диспетч парсера; source_ref — человекочитаемый референс (имя раздачи/файла), не адрес добавления. - worker: общий sourceAddParts ветвит по source_type в ОБОИХ add-путях — processCatched и Retry (torrent добавляется файлом, не magnet-хешем). - Транспорты: multipart-форма с файл-пикером (деградация без JS) и приём Telegram-документа (скачивание с редактированием токена из ошибок — секрет не в логи; обработка до ветки pending/текста). Разработка по OpenSpec (SDD): change torrent-file-ingest, два чекпоинта ревью (дизайн до кода, код до архива) сабагентами; дельты влиты в спеки, change архивирован. Ручная проверка на живом qBittorrent (7.3) — за деплоем. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+12
-1
@@ -13,7 +13,7 @@
|
|||||||
> `0003_source_miss_count`, `0004_candidate_url`, `0005_display_name`,
|
> `0003_source_miss_count`, `0004_candidate_url`, `0005_display_name`,
|
||||||
> `0006_ulid_identity` (Go-миграция: ULID-идентификаторы, `download_infohash`),
|
> `0006_ulid_identity` (Go-миграция: ULID-идентификаторы, `download_infohash`),
|
||||||
> `0007_file_link_size`, `0008_rfc3339_time` (метки времени → RFC 3339 UTC,
|
> `0007_file_link_size`, `0008_rfc3339_time` (метки времени → RFC 3339 UTC,
|
||||||
> `DEFAULT` убран).
|
> `DEFAULT` убран), `0009_download_torrent` (байты `.torrent`-файла).
|
||||||
|
|
||||||
Назначение таблиц и почему так — [architecture.md](architecture.md) →
|
Назначение таблиц и почему так — [architecture.md](architecture.md) →
|
||||||
«Хранилище». Значения `state` и переходы — [workflow.md](workflow.md).
|
«Хранилище». Значения `state` и переходы — [workflow.md](workflow.md).
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
```mermaid
|
```mermaid
|
||||||
erDiagram
|
erDiagram
|
||||||
download ||--o{ download_infohash : "инфохэши (v1/v2)"
|
download ||--o{ download_infohash : "инфохэши (v1/v2)"
|
||||||
|
download ||--o| download_torrent : "байты .torrent (1:0..1)"
|
||||||
download ||--o{ recognition : "распознавания"
|
download ||--o{ recognition : "распознавания"
|
||||||
download ||--o{ hint : "подсказки"
|
download ||--o{ hint : "подсказки"
|
||||||
download ||--o{ override : "ручные правки"
|
download ||--o{ override : "ручные правки"
|
||||||
@@ -55,6 +56,11 @@ erDiagram
|
|||||||
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
download_torrent {
|
||||||
|
TEXT download_id PK_FK "NOT NULL; ON DELETE CASCADE; байты source_type=torrent"
|
||||||
|
BLOB data "NOT NULL; исходные байты .torrent для добавления файлом (миграция 0009)"
|
||||||
|
}
|
||||||
|
|
||||||
recognition {
|
recognition {
|
||||||
TEXT id PK "ULID"
|
TEXT id PK "ULID"
|
||||||
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
|
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
|
||||||
@@ -125,6 +131,11 @@ erDiagram
|
|||||||
более одной активной загрузки на infohash» держат guarded-методы store
|
более одной активной загрузки на infohash» держат guarded-методы store
|
||||||
(`CreateDownloadIfNoActive`/`ActivateIfNoOtherActive`) в одной
|
(`CreateDownloadIfNoActive`/`ActivateIfNoOtherActive`) в одной
|
||||||
write-транзакции — на уровне схемы он не выражается (условие на `state`).
|
write-транзакции — на уровне схемы он не выражается (условие на `state`).
|
||||||
|
- `download` 1 — 0..1 `download_torrent` — байты исходного `.torrent` (только
|
||||||
|
у `source_type=torrent`); нужны воркеру для добавления раздачи файлом и для
|
||||||
|
повторного добавления при retry, поэтому живут весь срок строки загрузки.
|
||||||
|
`ON DELETE CASCADE` — страховка на будущий delete-путь (сейчас загрузки не
|
||||||
|
удаляются).
|
||||||
- `download` ↔ `file_link` — один источник (раздача) ко многим разложенным
|
- `download` ↔ `file_link` — один источник (раздача) ко многим разложенным
|
||||||
файлам; внутри строки `file_link` связь `src_path → dst_path` — 1:1. Не
|
файлам; внутри строки `file_link` связь `src_path → dst_path` — 1:1. Не
|
||||||
каждый файл раздачи попадает в `file_link` (только распознанные медиа и
|
каждый файл раздачи попадает в `file_link` (только распознанные медиа и
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module git.vakhrushev.me/av/jellybit
|
|||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/anacrolix/torrent v1.61.0
|
||||||
github.com/go-chi/chi/v5 v5.1.0
|
github.com/go-chi/chi/v5 v5.1.0
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||||
github.com/jmoiron/sqlx v1.4.0
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
@@ -14,17 +15,30 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b // indirect
|
||||||
|
github.com/anacrolix/missinggo v1.3.0 // indirect
|
||||||
|
github.com/anacrolix/missinggo/v2 v2.10.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
|
github.com/huandu/xstrings v1.3.2 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.3 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||||
|
github.com/minio/sha256-simd v1.0.0 // indirect
|
||||||
|
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||||
|
github.com/multiformats/go-multihash v0.2.3 // indirect
|
||||||
|
github.com/multiformats/go-varint v0.0.6 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||||
|
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/sync v0.8.0 // indirect
|
golang.org/x/crypto v0.44.0 // indirect
|
||||||
golang.org/x/sys v0.25.0 // indirect
|
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
|
||||||
|
golang.org/x/sync v0.18.0 // indirect
|
||||||
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
|
lukechampine.com/blake3 v1.1.6 // indirect
|
||||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||||
modernc.org/libc v1.55.3 // indirect
|
modernc.org/libc v1.55.3 // indirect
|
||||||
modernc.org/mathutil v1.6.0 // indirect
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
|
|||||||
@@ -1,63 +1,339 @@
|
|||||||
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
|
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
|
crawshaw.io/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oXPKINnZ8mcqnye1EkVkqsectk=
|
||||||
|
crawshaw.io/sqlite v0.3.2/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4=
|
||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
|
github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w=
|
||||||
|
github.com/RoaringBitmap/roaring v0.4.17/go.mod h1:D3qVegWTmfCaX4Bl5CrBE9hfrSrrXIr8KVNvRsDi1NI=
|
||||||
|
github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo=
|
||||||
|
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
|
||||||
|
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
|
||||||
|
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||||
|
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||||
|
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||||
|
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||||
|
github.com/anacrolix/dht/v2 v2.23.0 h1:EuD17ykTTEkAMPLjBsS5QjGOwuBgLTdQhds6zPAjeVY=
|
||||||
|
github.com/anacrolix/dht/v2 v2.23.0/go.mod h1:seXRz6HLw8zEnxlysf9ye2eQbrKUmch6PyOHpe/Nb/U=
|
||||||
|
github.com/anacrolix/envpprof v0.0.0-20180404065416-323002cec2fa/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c=
|
||||||
|
github.com/anacrolix/envpprof v1.0.0/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c=
|
||||||
|
github.com/anacrolix/envpprof v1.1.0/go.mod h1:My7T5oSqVfEn4MD4Meczkw/f5lSIndGAKu/0SM/rkf4=
|
||||||
|
github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b h1:Kuvx/A/TTJuT9x8mn7DeGx2KW9tWn1LI8bira67xdT0=
|
||||||
|
github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b/go.mod h1:NGehhfeXJPBujPx0s6cstSj8B+TERsTY32Xckfx5ftc=
|
||||||
|
github.com/anacrolix/log v0.3.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU=
|
||||||
|
github.com/anacrolix/log v0.6.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU=
|
||||||
|
github.com/anacrolix/missinggo v1.1.0/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo=
|
||||||
|
github.com/anacrolix/missinggo v1.1.2-0.20190815015349-b888af804467/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo=
|
||||||
|
github.com/anacrolix/missinggo v1.2.1/go.mod h1:J5cMhif8jPmFoC3+Uvob3OXXNIhOUikzMt+uUjeM21Y=
|
||||||
|
github.com/anacrolix/missinggo v1.3.0 h1:06HlMsudotL7BAELRZs0yDZ4yVXsHXGi323QBjAVASw=
|
||||||
|
github.com/anacrolix/missinggo v1.3.0/go.mod h1:bqHm8cE8xr+15uVfMG3BFui/TxyB6//H5fwlq/TeqMc=
|
||||||
|
github.com/anacrolix/missinggo/perf v1.0.0/go.mod h1:ljAFWkBuzkO12MQclXzZrosP5urunoLS0Cbvb4V0uMQ=
|
||||||
|
github.com/anacrolix/missinggo/v2 v2.2.0/go.mod h1:o0jgJoYOyaoYQ4E2ZMISVa9c88BbUBVQQW4QeRkNCGY=
|
||||||
|
github.com/anacrolix/missinggo/v2 v2.5.1/go.mod h1:WEjqh2rmKECd0t1VhQkLGTdIWXO6f6NLjp5GlMZ+6FA=
|
||||||
|
github.com/anacrolix/missinggo/v2 v2.10.0 h1:pg0iO4Z/UhP2MAnmGcaMtp5ZP9kyWsusENWN9aolrkY=
|
||||||
|
github.com/anacrolix/missinggo/v2 v2.10.0/go.mod h1:nCRMW6bRCMOVcw5z9BnSYKF+kDbtenx+hQuphf4bK8Y=
|
||||||
|
github.com/anacrolix/multiless v0.4.0 h1:lqSszHkliMsZd2hsyrDvHOw4AbYWa+ijQ66LzbjqWjM=
|
||||||
|
github.com/anacrolix/multiless v0.4.0/go.mod h1:zJv1JF9AqdZiHwxqPgjuOZDGWER6nyE48WBCi/OOrMM=
|
||||||
|
github.com/anacrolix/stm v0.2.0/go.mod h1:zoVQRvSiGjGoTmbM0vSLIiaKjWtNPeTvXUSdJQA4hsg=
|
||||||
|
github.com/anacrolix/tagflag v0.0.0-20180109131632-2146c8d41bf0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw=
|
||||||
|
github.com/anacrolix/tagflag v1.0.0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw=
|
||||||
|
github.com/anacrolix/tagflag v1.1.0/go.mod h1:Scxs9CV10NQatSmbyjqmqmeQNwGzlNe0CMUMIxqHIG8=
|
||||||
|
github.com/anacrolix/torrent v1.61.0 h1:vxo+B4SwnoP5AQWbhvnTYIaTgPSX+llYUVuQVsN4Jg8=
|
||||||
|
github.com/anacrolix/torrent v1.61.0/go.mod h1:yKUKuZSSDdyOsCbuH+rDOpswl/g546gICapdrU7aUmQ=
|
||||||
|
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
|
||||||
|
github.com/benbjohnson/immutable v0.2.0/go.mod h1:uc6OHo6PN2++n98KHLxW8ef4W42ylHiQSENghE1ezxI=
|
||||||
|
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||||
|
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
|
||||||
|
github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
|
||||||
|
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8=
|
||||||
|
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og=
|
||||||
|
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
|
||||||
|
github.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||||
|
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
|
||||||
|
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
|
||||||
|
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
|
github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
|
||||||
|
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
|
||||||
|
github.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
|
||||||
|
github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
|
||||||
|
github.com/glycerine/goconvey v0.0.0-20190315024820-982ee783a72e/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
|
||||||
|
github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
|
||||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
|
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||||
|
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||||
|
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||||
|
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||||
|
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||||
|
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
|
||||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
|
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
||||||
|
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||||
|
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||||
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
|
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
|
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/google/btree v0.0.0-20180124185431-e89373fe6b4a/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
|
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||||
|
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||||
|
github.com/gopherjs/gopherjs v0.0.0-20190309154008-847fc94819f9/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||||
|
github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||||
|
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
||||||
|
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
|
github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo=
|
||||||
|
github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4=
|
||||||
|
github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||||
|
github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw=
|
||||||
|
github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||||
|
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||||
|
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||||
|
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||||
|
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||||
|
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||||
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
|
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||||
github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4 h1:C/VViMMbR/4Ti2aXrWpKy34S05cRaVd6EvV9BFR3qJ8=
|
github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4 h1:C/VViMMbR/4Ti2aXrWpKy34S05cRaVd6EvV9BFR3qJ8=
|
||||||
github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4/go.mod h1:H66QhXPJpUSdWschhL6u//v3ge96/qMnQ9mWp3efbxA=
|
github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4/go.mod h1:H66QhXPJpUSdWschhL6u//v3ge96/qMnQ9mWp3efbxA=
|
||||||
|
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
|
||||||
|
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
|
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
|
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||||
|
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||||
|
github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg=
|
||||||
|
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
|
||||||
|
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
|
||||||
|
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
|
||||||
|
github.com/multiformats/go-varint v0.0.6 h1:gk85QWKxh3TazbLxED/NlDVv8+q+ReFJk7Y2W/KhfNY=
|
||||||
|
github.com/multiformats/go-varint v0.0.6/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE=
|
||||||
|
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
|
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
|
||||||
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||||
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
|
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
|
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||||
|
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
|
||||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||||
|
github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
|
||||||
|
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
|
||||||
|
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pressly/goose/v3 v3.22.1 h1:2zICEfr1O3yTP9BRZMGPj7qFxQ+ik6yeo+z1LMuioLc=
|
github.com/pressly/goose/v3 v3.22.1 h1:2zICEfr1O3yTP9BRZMGPj7qFxQ+ik6yeo+z1LMuioLc=
|
||||||
github.com/pressly/goose/v3 v3.22.1/go.mod h1:xtMpbstWyCpyH+0cxLTMCENWBG+0CSxvTsXhW95d5eo=
|
github.com/pressly/goose/v3 v3.22.1/go.mod h1:xtMpbstWyCpyH+0cxLTMCENWBG+0CSxvTsXhW95d5eo=
|
||||||
|
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||||
|
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
|
||||||
|
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||||
|
github.com/prometheus/client_golang v1.5.1/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
|
||||||
|
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||||
|
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||||
|
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
|
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
|
github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||||
|
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||||
|
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
|
||||||
|
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||||
|
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||||
|
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||||
|
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
|
||||||
|
github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
|
||||||
|
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
|
||||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||||
|
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||||
|
github.com/smartystreets/assertions v0.0.0-20190215210624-980c5ac6f3ac/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||||
|
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
|
||||||
|
github.com/smartystreets/goconvey v0.0.0-20190306220146-200a235640ff/go.mod h1:KSQcGKpxUMHk3nbYzs/tIBAM2iDooCn0BmttHOJEbLs=
|
||||||
|
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||||
|
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
|
||||||
|
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
|
||||||
|
github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
|
||||||
|
github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
|
||||||
|
github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
|
||||||
|
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
|
||||||
|
go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
|
||||||
|
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||||
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
|
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
|
||||||
|
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
||||||
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
|
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||||
|
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||||
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
|
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
|
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||||
|
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
|
||||||
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
|
||||||
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
|
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
|
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||||
|
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c=
|
||||||
|
lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA=
|
||||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -25,6 +26,7 @@ import (
|
|||||||
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/torrent"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||||
"git.vakhrushev.me/av/jellybit/web"
|
"git.vakhrushev.me/av/jellybit/web"
|
||||||
)
|
)
|
||||||
@@ -395,14 +397,34 @@ func pageWindow(page, pages int) []int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := r.ParseForm(); err != nil {
|
// Форма — multipart (файл-пикер .torrent). Лимит тела: размер торрента +
|
||||||
redirectErr(w, r, "не удалось разобрать форму")
|
// небольшой запас на прочие поля. ParseMultipartForm сперва разбирает
|
||||||
|
// urlencoded-тело (ParseForm), поэтому на обычной (не-multipart) отправке —
|
||||||
|
// напр. из curl или устаревшей страницы — вернёт ErrNotMultipart, но поля
|
||||||
|
// уже в PostForm: такую ошибку глотаем, текстовый путь не ломается.
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, ingest.MaxTorrentSize+1<<20)
|
||||||
|
if err := r.ParseMultipartForm(ingest.MaxTorrentSize + 1<<20); err != nil && !errors.Is(err, http.ErrNotMultipart) {
|
||||||
|
redirectErr(w, r, "не удалось разобрать форму (возможно, файл слишком большой)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{
|
|
||||||
Source: r.PostForm.Get("source"),
|
req := ingest.Request{
|
||||||
Context: r.PostForm.Get("context"),
|
Source: r.FormValue("source"),
|
||||||
})
|
Context: r.FormValue("context"),
|
||||||
|
}
|
||||||
|
// Выбран .torrent-файл — приём по байтам (в приоритете над текстом).
|
||||||
|
if file, header, err := r.FormFile("torrent"); err == nil {
|
||||||
|
defer func() { _ = file.Close() }()
|
||||||
|
data, rerr := io.ReadAll(file)
|
||||||
|
if rerr != nil {
|
||||||
|
redirectErr(w, r, "не удалось прочитать .torrent-файл")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.TorrentData = data
|
||||||
|
req.TorrentName = header.Filename // фолбек для source_ref
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := s.deps.Ingestor.Ingest(r.Context(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
redirectErr(w, r, userErr(r, err, res.DownloadID))
|
redirectErr(w, r, userErr(r, err, res.DownloadID))
|
||||||
return
|
return
|
||||||
@@ -732,7 +754,7 @@ func classifyErr(err error) (int, string) {
|
|||||||
switch {
|
switch {
|
||||||
case errors.Is(err, store.ErrNotFound):
|
case errors.Is(err, store.ErrNotFound):
|
||||||
return http.StatusNotFound, "не найдено"
|
return http.StatusNotFound, "не найдено"
|
||||||
case errors.Is(err, magnet.ErrNotMagnet):
|
case errors.Is(err, magnet.ErrNotMagnet), errors.Is(err, torrent.ErrNotTorrent):
|
||||||
return http.StatusBadRequest, "некорректный источник"
|
return http.StatusBadRequest, "некорректный источник"
|
||||||
case errors.Is(err, worker.ErrConflict):
|
case errors.Is(err, worker.ErrConflict):
|
||||||
// Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
|
// Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package httpapi_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/httpapi"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Веб-форма с выбранным .torrent-файлом → приём по байтам (TorrentData).
|
||||||
|
func TestUIAddTorrentFile(t *testing.T) {
|
||||||
|
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateCatched}}
|
||||||
|
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&body)
|
||||||
|
_ = mw.WriteField("source", "") // источник пуст — используется файл
|
||||||
|
fw, _ := mw.CreateFormFile("torrent", "dune.torrent")
|
||||||
|
torrentBytes := []byte("d8:announce…bytes")
|
||||||
|
_, _ = fw.Write(torrentBytes)
|
||||||
|
_ = mw.Close()
|
||||||
|
|
||||||
|
resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads", mw.FormDataContentType(), &body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(ing.lastReq.TorrentData, torrentBytes) {
|
||||||
|
t.Errorf("TorrentData не проброшен в ingest: %q", ing.lastReq.TorrentData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Без файла — прежний текстовый путь (Source), TorrentData пуст.
|
||||||
|
func TestUIAddTextNoFile(t *testing.T) {
|
||||||
|
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateCatched}}
|
||||||
|
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&body)
|
||||||
|
_ = mw.WriteField("source", "magnet:?xt=urn:btih:abc")
|
||||||
|
_ = mw.Close()
|
||||||
|
|
||||||
|
resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads", mw.FormDataContentType(), &body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if len(ing.lastReq.TorrentData) != 0 {
|
||||||
|
t.Errorf("TorrentData должен быть пуст без файла")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(ing.lastReq.Source, "magnet:") {
|
||||||
|
t.Errorf("source не проброшен: %q", ing.lastReq.Source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обычная (не-multipart) urlencoded-отправка тоже работает (ErrNotMultipart
|
||||||
|
// глотается, поля уже в PostForm) — устойчивость к curl/старой странице.
|
||||||
|
func TestUIAddUrlencoded(t *testing.T) {
|
||||||
|
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateCatched}}
|
||||||
|
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
|
||||||
|
|
||||||
|
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads", url.Values{
|
||||||
|
"source": {"magnet:?xt=urn:btih:abc"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(ing.lastReq.Source, "magnet:") {
|
||||||
|
t.Errorf("urlencoded source не проброшен: %q", ing.lastReq.Source)
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
-25
@@ -15,6 +15,7 @@ import (
|
|||||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/torrent"
|
||||||
)
|
)
|
||||||
|
|
||||||
// capIngest — стадия приёма для поля capability в логах.
|
// capIngest — стадия приёма для поля capability в логах.
|
||||||
@@ -28,7 +29,9 @@ type Store interface {
|
|||||||
// CreateDownloadIfNoActive атомарно проверяет инвариант «одна активная
|
// CreateDownloadIfNoActive атомарно проверяет инвариант «одна активная
|
||||||
// загрузка на infohash» и заводит задачу; вернувшаяся existing ≠ nil —
|
// загрузка на infohash» и заводит задачу; вернувшаяся existing ≠ nil —
|
||||||
// дедуп на активную задачу (недостающие хеши вызова метод доносит сам).
|
// дедуп на активную задачу (недостающие хеши вызова метод доносит сам).
|
||||||
CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error)
|
// torrentBlob (для source_type=torrent) пишется в той же транзакции только
|
||||||
|
// на ветке создания; при дедупе не пишется. Для magnet — nil.
|
||||||
|
CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error)
|
||||||
// AddInfohashes доносит задаче недостающие хеши (guarded). Нужен на
|
// AddInfohashes доносит задаче недостающие хеши (guarded). Нужен на
|
||||||
// быстром дедуп-пути, который не доходит до CreateDownloadIfNoActive.
|
// быстром дедуп-пути, который не доходит до CreateDownloadIfNoActive.
|
||||||
AddInfohashes(ctx context.Context, downloadID string, hashes []string) error
|
AddInfohashes(ctx context.Context, downloadID string, hashes []string) error
|
||||||
@@ -45,10 +48,13 @@ func New(st Store, log *slog.Logger) *Service {
|
|||||||
return &Service{store: st, log: log}
|
return &Service{store: st, log: log}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request — входной запрос приёма.
|
// Request — входной запрос приёма. Источник задаётся либо строкой (magnet), либо
|
||||||
|
// байтами `.torrent` (TorrentData); при непустом TorrentData он в приоритете.
|
||||||
type Request struct {
|
type Request struct {
|
||||||
Source string // пока — magnet-ссылка
|
Source string // magnet-ссылка (когда TorrentData пуст)
|
||||||
Context string // подсказка для распознавания (опц.)
|
TorrentData []byte // байты .torrent-файла (опц.); при наличии — источник torrent
|
||||||
|
TorrentName string // имя загруженного файла (опц.); фолбек для source_ref
|
||||||
|
Context string // подсказка для распознавания (опц.)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result — итог приёма.
|
// Result — итог приёма.
|
||||||
@@ -64,39 +70,39 @@ type Result struct {
|
|||||||
// `catched` и сразу возвращает результат. Добавление в qBittorrent и вывод
|
// `catched` и сразу возвращает результат. Добавление в qBittorrent и вывод
|
||||||
// имени выполняет worker (см. download-tracking).
|
// имени выполняет worker (см. download-tracking).
|
||||||
func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||||
source := strings.TrimSpace(req.Source)
|
src, err := s.parse(req)
|
||||||
info, err := magnet.Parse(source)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Ф1: поддержан только magnet. .torrent/url — следующий заход.
|
return Result{}, err
|
||||||
return Result{}, fmt.Errorf("ingest: parse source: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scoped-логгер стадии приёма: download_id допишется после CreateDownload.
|
// Scoped-логгер стадии приёма: download_id допишется после CreateDownload.
|
||||||
log := s.log.With("capability", capIngest, "infohash", info.Infohash)
|
log := s.log.With("capability", capIngest, "infohash", src.infohashes[0])
|
||||||
ctx = logctx.With(ctx, log)
|
ctx = logctx.With(ctx, log)
|
||||||
|
|
||||||
// Быстрый дедуп-чек; авторитетная (атомарная) проверка — внутри
|
// Быстрый дедуп-чек; авторитетная (атомарная) проверка — внутри
|
||||||
// CreateDownloadIfNoActive ниже. Дедуп — по ЛЮБОМУ из хешей источника:
|
// CreateDownloadIfNoActive ниже. Дедуп — по ЛЮБОМУ из хешей источника:
|
||||||
// гибридный magnet несёт и v1, и v2.
|
// гибридный несёт и v1, и v2.
|
||||||
if existing, err := s.store.FindActiveByInfohash(ctx, info.Infohashes...); err != nil {
|
if existing, err := s.store.FindActiveByInfohash(ctx, src.infohashes...); err != nil {
|
||||||
return Result{}, fmt.Errorf("ingest: lookup active: %w", err)
|
return Result{}, fmt.Errorf("ingest: lookup active: %w", err)
|
||||||
} else if existing != nil {
|
} else if existing != nil {
|
||||||
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
|
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
|
||||||
return s.attached(ctx, info, existing), nil
|
return s.attached(ctx, src, existing), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Контекст распознавания дополняем фактами из полей самой magnet-ссылки
|
// Контекст распознавания дополняем фактами из полей источника (magnet-поля
|
||||||
// (dn/xl/tr/xs/kt) — без сети. Пользовательский текст идёт первым.
|
// или дерево `.torrent`) — без сети. Пользовательский текст идёт первым.
|
||||||
// DisplayName пуст: имя выведет worker на шаге добавления (rename действует
|
// DisplayName пуст: имя выведет worker на шаге добавления (rename действует
|
||||||
// только при добавлении, а тут медленный LLM в пути ответа недопустим).
|
// только при добавлении, а тут медленный LLM в пути ответа недопустим).
|
||||||
d := &store.Download{
|
d := &store.Download{
|
||||||
SourceType: store.SourceMagnet,
|
SourceType: src.sourceType,
|
||||||
SourceRef: source,
|
SourceRef: src.sourceRef,
|
||||||
Context: mergeContext(req.Context, info.Context()),
|
Context: mergeContext(req.Context, src.synthContext),
|
||||||
State: store.StateCatched,
|
State: store.StateCatched,
|
||||||
}
|
}
|
||||||
// Все хеши из magnet (гибридный несёт v1 и v2); kind store выведет по длине.
|
// Все хеши источника (гибрид несёт v1 и v2); kind store выведет по длине.
|
||||||
existing, err := s.store.CreateDownloadIfNoActive(ctx, d, info.Infohashes)
|
// torrentBlob непуст только для source_type=torrent — пишется в той же
|
||||||
|
// транзакции лишь на ветке создания.
|
||||||
|
existing, err := s.store.CreateDownloadIfNoActive(ctx, d, src.infohashes, src.torrentBlob)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, fmt.Errorf("ingest: create download: %w", err)
|
return Result{}, fmt.Errorf("ingest: create download: %w", err)
|
||||||
}
|
}
|
||||||
@@ -107,7 +113,7 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
|||||||
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
|
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
|
||||||
return Result{
|
return Result{
|
||||||
DownloadID: existing.ID,
|
DownloadID: existing.ID,
|
||||||
Infohashes: info.Infohashes,
|
Infohashes: src.infohashes,
|
||||||
State: existing.State,
|
State: existing.State,
|
||||||
Deduplicated: true,
|
Deduplicated: true,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -116,11 +122,67 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
|||||||
log.Info("download catched", "download_id", d.ID)
|
log.Info("download catched", "download_id", d.ID)
|
||||||
return Result{
|
return Result{
|
||||||
DownloadID: d.ID,
|
DownloadID: d.ID,
|
||||||
Infohashes: info.Infohashes,
|
Infohashes: src.infohashes,
|
||||||
State: store.StateCatched,
|
State: store.StateCatched,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MaxTorrentSize — предел размера принимаемого `.torrent` (защита от разбухания
|
||||||
|
// БД и oversized-загрузок). Реальные торрент-файлы много меньше; крупные (много
|
||||||
|
// файлов → много piece-хешей) отсекаются здесь.
|
||||||
|
const MaxTorrentSize = 8 << 20 // 8 MiB
|
||||||
|
|
||||||
|
// parsedSource — нормализованный источник приёма (magnet или .torrent).
|
||||||
|
type parsedSource struct {
|
||||||
|
sourceType store.SourceType
|
||||||
|
sourceRef string // референс для человека/логов (magnet-URI или имя torrent)
|
||||||
|
infohashes []string // все хеши источника; v1 раньше v2
|
||||||
|
synthContext string // синтез контекста из полей источника (без сети)
|
||||||
|
torrentBlob []byte // байты .torrent (для source_type=torrent); иначе nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse разбирает источник запроса: при непустом TorrentData — как `.torrent`
|
||||||
|
// (в приоритете), иначе — как magnet. Синтез контекста и хеши берутся из полей
|
||||||
|
// источника, без сети.
|
||||||
|
func (s *Service) parse(req Request) (parsedSource, error) {
|
||||||
|
if len(req.TorrentData) > 0 {
|
||||||
|
if len(req.TorrentData) > MaxTorrentSize {
|
||||||
|
return parsedSource{}, fmt.Errorf("ingest: torrent too large: %d > %d bytes", len(req.TorrentData), MaxTorrentSize)
|
||||||
|
}
|
||||||
|
info, err := torrent.Parse(req.TorrentData)
|
||||||
|
if err != nil {
|
||||||
|
return parsedSource{}, fmt.Errorf("ingest: parse torrent: %w", err)
|
||||||
|
}
|
||||||
|
// SourceRef — человекочитаемый референс (имя раздачи), НЕ адрес
|
||||||
|
// добавления: torrent добавляется байтами (см. worker), не по SourceRef.
|
||||||
|
// Фолбек на имя файла, если у раздачи нет содержательного имени
|
||||||
|
// (пустое или NoName-сентинел "-").
|
||||||
|
ref := strings.TrimSpace(info.DisplayName)
|
||||||
|
if ref == "" || ref == "-" {
|
||||||
|
ref = strings.TrimSpace(req.TorrentName)
|
||||||
|
}
|
||||||
|
return parsedSource{
|
||||||
|
sourceType: store.SourceTorrent,
|
||||||
|
sourceRef: ref,
|
||||||
|
infohashes: info.Infohashes,
|
||||||
|
synthContext: info.Context(),
|
||||||
|
torrentBlob: req.TorrentData,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
source := strings.TrimSpace(req.Source)
|
||||||
|
info, err := magnet.Parse(source)
|
||||||
|
if err != nil {
|
||||||
|
return parsedSource{}, fmt.Errorf("ingest: parse source: %w", err)
|
||||||
|
}
|
||||||
|
return parsedSource{
|
||||||
|
sourceType: store.SourceMagnet,
|
||||||
|
sourceRef: source,
|
||||||
|
infohashes: info.Infohashes,
|
||||||
|
synthContext: info.Context(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// mergeContext склеивает контекст от транспорта с синтезом из полей magnet:
|
// mergeContext склеивает контекст от транспорта с синтезом из полей magnet:
|
||||||
// пользовательский текст идёт первым, затем факты из ссылки. Пустые части
|
// пользовательский текст идёт первым, затем факты из ссылки. Пустые части
|
||||||
// опускаются; при пустых обеих — пустая строка (пустой контекст допустим).
|
// опускаются; при пустых обеих — пустая строка (пустой контекст допустим).
|
||||||
@@ -140,15 +202,15 @@ func mergeContext(userText, synth string) string {
|
|||||||
// принести хеш, которого задача ещё не знает; guarded-путь через
|
// принести хеш, которого задача ещё не знает; guarded-путь через
|
||||||
// CreateDownloadIfNoActive сюда не доходит). Донос — best-effort: конфликт
|
// CreateDownloadIfNoActive сюда не доходит). Донос — best-effort: конфликт
|
||||||
// хеша с другой активной задачей логируется, приём не валится.
|
// хеша с другой активной задачей логируется, приём не валится.
|
||||||
func (s *Service) attached(ctx context.Context, info magnet.Info, existing *store.Download) Result {
|
func (s *Service) attached(ctx context.Context, src parsedSource, existing *store.Download) Result {
|
||||||
if len(info.Infohashes) > len(existing.Infohashes) {
|
if len(src.infohashes) > len(existing.Infohashes) {
|
||||||
if err := s.store.AddInfohashes(ctx, existing.ID, info.Infohashes); err != nil {
|
if err := s.store.AddInfohashes(ctx, existing.ID, src.infohashes); err != nil {
|
||||||
logctx.FromOr(ctx, s.log).Warn("ingest top-up infohashes failed", "error", err)
|
logctx.FromOr(ctx, s.log).Warn("ingest top-up infohashes failed", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Result{
|
return Result{
|
||||||
DownloadID: existing.ID,
|
DownloadID: existing.ID,
|
||||||
Infohashes: info.Infohashes,
|
Infohashes: src.infohashes,
|
||||||
State: existing.State,
|
State: existing.State,
|
||||||
Deduplicated: true,
|
Deduplicated: true,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type fakeStore struct {
|
|||||||
active *store.Download
|
active *store.Download
|
||||||
created []store.Download
|
created []store.Download
|
||||||
hashes [][]string
|
hashes [][]string
|
||||||
|
blobs [][]byte
|
||||||
toppedUp []string
|
toppedUp []string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,13 +27,14 @@ func (f *fakeStore) FindActiveByInfohash(_ context.Context, _ ...string) (*store
|
|||||||
return f.active, nil
|
return f.active, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) CreateDownloadIfNoActive(_ context.Context, d *store.Download, hashes []string) (*store.Download, error) {
|
func (f *fakeStore) CreateDownloadIfNoActive(_ context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error) {
|
||||||
if f.active != nil {
|
if f.active != nil {
|
||||||
return f.active, nil
|
return f.active, nil
|
||||||
}
|
}
|
||||||
d.ID = ident.NewID()
|
d.ID = ident.NewID()
|
||||||
f.created = append(f.created, *d)
|
f.created = append(f.created, *d)
|
||||||
f.hashes = append(f.hashes, hashes)
|
f.hashes = append(f.hashes, hashes)
|
||||||
|
f.blobs = append(f.blobs, torrentBlob)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package ingest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/hex"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/anacrolix/torrent/bencode"
|
||||||
|
"github.com/anacrolix/torrent/metainfo"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildTorrent собирает валидные байты .torrent и ожидаемый v1-инфохэш.
|
||||||
|
func buildTorrent(t *testing.T, name, announce string) (data []byte, infohash string) {
|
||||||
|
t.Helper()
|
||||||
|
info := metainfo.Info{Name: name, Length: 1024, PieceLength: 512, Pieces: make([]byte, 40)}
|
||||||
|
infoBytes, err := bencode.Marshal(info)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal info: %v", err)
|
||||||
|
}
|
||||||
|
sum := sha1.Sum(infoBytes)
|
||||||
|
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: announce}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := mi.Write(&buf); err != nil {
|
||||||
|
t.Fatalf("write metainfo: %v", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes(), hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestTorrentFile(t *testing.T) {
|
||||||
|
data, wantHash := buildTorrent(t, "Dune.Part.Two.2024.mkv", "http://bt.rutracker.org/ann")
|
||||||
|
fs := &fakeStore{}
|
||||||
|
res, err := newService(fs).Ingest(context.Background(), Request{TorrentData: data, Context: "мой текст"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Ingest: %v", err)
|
||||||
|
}
|
||||||
|
if res.State != store.StateCatched || res.Deduplicated {
|
||||||
|
t.Errorf("res = %+v", res)
|
||||||
|
}
|
||||||
|
if len(res.Infohashes) != 1 || res.Infohashes[0] != wantHash {
|
||||||
|
t.Errorf("infohashes = %v, want [%s]", res.Infohashes, wantHash)
|
||||||
|
}
|
||||||
|
if len(fs.created) != 1 {
|
||||||
|
t.Fatalf("создано задач: %d", len(fs.created))
|
||||||
|
}
|
||||||
|
d := fs.created[0]
|
||||||
|
if d.SourceType != store.SourceTorrent {
|
||||||
|
t.Errorf("source_type = %q, want torrent", d.SourceType)
|
||||||
|
}
|
||||||
|
if d.SourceRef != "Dune.Part.Two.2024.mkv" {
|
||||||
|
t.Errorf("source_ref = %q (имя раздачи, не URL)", d.SourceRef)
|
||||||
|
}
|
||||||
|
// Контекст: текст пользователя первым, затем синтез из полей файла.
|
||||||
|
if !strings.HasPrefix(d.Context, "мой текст") {
|
||||||
|
t.Errorf("context не с текста пользователя: %q", d.Context)
|
||||||
|
}
|
||||||
|
if !strings.Contains(d.Context, "Dune.Part.Two") || !strings.Contains(d.Context, "rutracker.org") {
|
||||||
|
t.Errorf("context без синтеза из файла: %q", d.Context)
|
||||||
|
}
|
||||||
|
// Байты сохранены в транзакции создания.
|
||||||
|
if len(fs.blobs) != 1 || !bytes.Equal(fs.blobs[0], data) {
|
||||||
|
t.Errorf("байты .torrent не переданы в CreateDownloadIfNoActive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestTorrentDedupNoBlob(t *testing.T) {
|
||||||
|
data, _ := buildTorrent(t, "X", "http://t/ann")
|
||||||
|
// Активная задача уже есть → дедуп; байты писаться не должны.
|
||||||
|
fs := &fakeStore{active: &store.Download{ID: "existing", State: store.StateDownloading}}
|
||||||
|
res, err := newService(fs).Ingest(context.Background(), Request{TorrentData: data})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Ingest: %v", err)
|
||||||
|
}
|
||||||
|
if !res.Deduplicated || res.DownloadID != "existing" {
|
||||||
|
t.Errorf("ожидался дедуп, res = %+v", res)
|
||||||
|
}
|
||||||
|
if len(fs.created) != 0 || len(fs.blobs) != 0 {
|
||||||
|
t.Errorf("при дедупе не должно быть создания/записи байтов")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestTorrentTooLarge(t *testing.T) {
|
||||||
|
fs := &fakeStore{}
|
||||||
|
big := make([]byte, MaxTorrentSize+1)
|
||||||
|
_, err := newService(fs).Ingest(context.Background(), Request{TorrentData: big})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ожидалась ошибка превышения размера")
|
||||||
|
}
|
||||||
|
if len(fs.created) != 0 {
|
||||||
|
t.Errorf("при превышении размера задача не создаётся")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// У раздачи без имени source_ref берётся из имени файла (фолбек).
|
||||||
|
func TestIngestTorrentNameFallback(t *testing.T) {
|
||||||
|
// Info без name → BestName() == "" → фолбек на TorrentName.
|
||||||
|
info := metainfo.Info{Name: "", Length: 1024, PieceLength: 512, Pieces: make([]byte, 40)}
|
||||||
|
infoBytes, err := bencode.Marshal(info)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: "http://t/ann"}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := mi.Write(&buf); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fs := &fakeStore{}
|
||||||
|
_, err = newService(fs).Ingest(context.Background(),
|
||||||
|
Request{TorrentData: buf.Bytes(), TorrentName: "Fallback.Name.torrent"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Ingest: %v", err)
|
||||||
|
}
|
||||||
|
if len(fs.created) != 1 || fs.created[0].SourceRef != "Fallback.Name.torrent" {
|
||||||
|
t.Errorf("source_ref = %q, want фолбек на имя файла", fs.created[0].SourceRef)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestTorrentInvalid(t *testing.T) {
|
||||||
|
fs := &fakeStore{}
|
||||||
|
_, err := newService(fs).Ingest(context.Background(), Request{TorrentData: []byte("not a torrent")})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ожидалась ошибка разбора .torrent")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -246,7 +246,12 @@ func NullString(s string) sql.NullString {
|
|||||||
// infohash» и заводит загрузку: если активная задача с любым из hashes уже
|
// infohash» и заводит загрузку: если активная задача с любым из hashes уже
|
||||||
// есть — возвращает её (дедуп, ничего не создавая); иначе вставляет d с новым
|
// есть — возвращает её (дедуп, ничего не создавая); иначе вставляет d с новым
|
||||||
// ULID и его хешами и возвращает (nil, nil). d.ID и d.Infohashes заполняются.
|
// ULID и его хешами и возвращает (nil, nil). d.ID и d.Infohashes заполняются.
|
||||||
func (s *Store) CreateDownloadIfNoActive(ctx context.Context, d *Download, hashes []string) (*Download, error) {
|
//
|
||||||
|
// torrentBlob (если непуст) — исходные байты `.torrent`; пишутся в
|
||||||
|
// download_torrent В ТОЙ ЖЕ транзакции только на ветке создания (для
|
||||||
|
// source_type=torrent, чтобы воркер добавил раздачу файлом). При дедупе байты
|
||||||
|
// не пишутся. Для magnet/url — nil.
|
||||||
|
func (s *Store) CreateDownloadIfNoActive(ctx context.Context, d *Download, hashes []string, torrentBlob []byte) (*Download, error) {
|
||||||
norm := normalizeHashes(hashes)
|
norm := normalizeHashes(hashes)
|
||||||
if len(norm) == 0 {
|
if len(norm) == 0 {
|
||||||
return nil, fmt.Errorf("create download: no infohash")
|
return nil, fmt.Errorf("create download: no infohash")
|
||||||
@@ -301,12 +306,35 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|||||||
}
|
}
|
||||||
d.Infohashes = append(d.Infohashes, Infohash{DownloadID: d.ID, Infohash: h, Kind: HashKind(h)})
|
d.Infohashes = append(d.Infohashes, Infohash{DownloadID: d.ID, Infohash: h, Kind: HashKind(h)})
|
||||||
}
|
}
|
||||||
|
if len(torrentBlob) > 0 {
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO download_torrent (download_id, data) VALUES (?, ?)`,
|
||||||
|
d.ID, torrentBlob); err != nil {
|
||||||
|
return nil, fmt.Errorf("insert download torrent: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return nil, fmt.Errorf("create download: commit: %w", err)
|
return nil, fmt.Errorf("create download: commit: %w", err)
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTorrentData возвращает сохранённые байты `.torrent` загрузки (для
|
||||||
|
// добавления раздачи файлом воркером). ErrNotFound — если байтов нет (не
|
||||||
|
// torrent-источник или запись отсутствует).
|
||||||
|
func (s *Store) GetTorrentData(ctx context.Context, downloadID string) ([]byte, error) {
|
||||||
|
var data []byte
|
||||||
|
err := s.DB.QueryRowxContext(ctx,
|
||||||
|
`SELECT data FROM download_torrent WHERE download_id = ?`, downloadID).Scan(&data)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("torrent data for %s: %w", downloadID, ErrNotFound)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get torrent data: %w", err)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ActivateIfNoOtherActive атомарно возвращает загрузку в активное состояние
|
// ActivateIfNoOtherActive атомарно возвращает загрузку в активное состояние
|
||||||
// (retry/восстановление сверкой/relink): в одной write-транзакции проверяет,
|
// (retry/восстановление сверкой/relink): в одной write-транзакции проверяет,
|
||||||
// что никакая ДРУГАЯ активная загрузка не владеет любым из хешей этой, и
|
// что никакая ДРУГАЯ активная загрузка не владеет любым из хешей этой, и
|
||||||
|
|||||||
@@ -44,11 +44,11 @@ func TestCatchedIsActiveForDedup(t *testing.T) {
|
|||||||
const ih = "aabbccddeeff00112233445566778899aabbccdd"
|
const ih = "aabbccddeeff00112233445566778899aabbccdd"
|
||||||
|
|
||||||
d1 := newCatched()
|
d1 := newCatched()
|
||||||
if existing, err := st.CreateDownloadIfNoActive(ctx, d1, []string{ih}); err != nil || existing != nil {
|
if existing, err := st.CreateDownloadIfNoActive(ctx, d1, []string{ih}, nil); err != nil || existing != nil {
|
||||||
t.Fatalf("первый catched: existing=%v err=%v", existing, err)
|
t.Fatalf("первый catched: existing=%v err=%v", existing, err)
|
||||||
}
|
}
|
||||||
// Повторный приём того же хеша → дедуп на активную catched-задачу.
|
// Повторный приём того же хеша → дедуп на активную catched-задачу.
|
||||||
existing, err := st.CreateDownloadIfNoActive(ctx, newCatched(), []string{ih})
|
existing, err := st.CreateDownloadIfNoActive(ctx, newCatched(), []string{ih}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("повторный приём: %v", err)
|
t.Fatalf("повторный приём: %v", err)
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ func TestPromoteCatched(t *testing.T) {
|
|||||||
const ih = "aabbccddeeff00112233445566778899aabbccdd"
|
const ih = "aabbccddeeff00112233445566778899aabbccdd"
|
||||||
|
|
||||||
d := newCatched()
|
d := newCatched()
|
||||||
if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}); err != nil {
|
if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}, nil); err != nil {
|
||||||
t.Fatalf("create: %v", err)
|
t.Fatalf("create: %v", err)
|
||||||
}
|
}
|
||||||
if err := st.PromoteCatched(ctx, d.ID, "Дюна (2024)"); err != nil {
|
if err := st.PromoteCatched(ctx, d.ID, "Дюна (2024)"); err != nil {
|
||||||
@@ -90,7 +90,7 @@ func TestPromoteCatched(t *testing.T) {
|
|||||||
func mustCreate(t *testing.T, st *Store, infohash string) string {
|
func mustCreate(t *testing.T, st *Store, infohash string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
d := newDownloading()
|
d := newDownloading()
|
||||||
existing, err := st.CreateDownloadIfNoActive(context.Background(), d, []string{infohash})
|
existing, err := st.CreateDownloadIfNoActive(context.Background(), d, []string{infohash}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create: %v", err)
|
t.Fatalf("create: %v", err)
|
||||||
}
|
}
|
||||||
@@ -229,7 +229,7 @@ func TestActiveDuplicateDeduplicated(t *testing.T) {
|
|||||||
id := mustCreate(t, st, ih)
|
id := mustCreate(t, st, ih)
|
||||||
|
|
||||||
d := newDownloading()
|
d := newDownloading()
|
||||||
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih})
|
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -254,7 +254,7 @@ func TestDedupByAnyHash(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2})
|
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -322,7 +322,7 @@ func TestCreateDedupTopsUpHashes(t *testing.T) {
|
|||||||
id := mustCreate(t, st, v1)
|
id := mustCreate(t, st, v1)
|
||||||
|
|
||||||
// Гибридный вызов с {v1, v2} дедупится на задачу и доносит ей v2.
|
// Гибридный вызов с {v1, v2} дедупится на задачу и доносит ей v2.
|
||||||
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v1, v2})
|
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v1, v2}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -333,7 +333,7 @@ func TestCreateDedupTopsUpHashes(t *testing.T) {
|
|||||||
t.Fatalf("хеши existing = %+v, want v1+v2 (top-up)", existing.Infohashes)
|
t.Fatalf("хеши existing = %+v, want v1+v2 (top-up)", existing.Infohashes)
|
||||||
}
|
}
|
||||||
// Теперь приём только по v2 тоже дедупится, а не создаёт вторую задачу.
|
// Теперь приём только по v2 тоже дедупится, а не создаёт вторую задачу.
|
||||||
byV2, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2})
|
byV2, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -437,7 +437,7 @@ func TestConcurrentCreateDedup(t *testing.T) {
|
|||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
d := newDownloading()
|
d := newDownloading()
|
||||||
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih})
|
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errs <- err
|
errs <- err
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func mkDownload(t *testing.T, st *Store, n int, state State, display string) str
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
d := newDownloading()
|
d := newDownloading()
|
||||||
d.DisplayName = display
|
d.DisplayName = display
|
||||||
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(n)})
|
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(n)}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create #%d: %v", n, err)
|
t.Fatalf("create #%d: %v", n, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Байты исходного `.torrent`-файла, привязанные к загрузке. Нужны воркеру на
|
||||||
|
-- шаге добавления в qBittorrent (быстрый приём лишь сохраняет `catched`):
|
||||||
|
-- torrent добавляется файлом, а не magnet-хешем — иначе на закрытых трекерах и
|
||||||
|
-- без DHT метаданные не докачаются. Отдельная таблица, а не колонка на
|
||||||
|
-- download: блоб не тянется в выборки списка/детали (SELECT * по download).
|
||||||
|
-- Байты живут весь срок строки загрузки (нужны для retry повторного добавления);
|
||||||
|
-- ON DELETE CASCADE — страховка на будущий delete-путь (сейчас загрузки не
|
||||||
|
-- удаляются).
|
||||||
|
CREATE TABLE download_torrent (
|
||||||
|
download_id TEXT PRIMARY KEY REFERENCES download (id) ON DELETE CASCADE,
|
||||||
|
data BLOB NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE download_torrent;
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Запись байтов .torrent на ветке создания и чтение их воркером.
|
||||||
|
func TestTorrentBlobWriteRead(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
blob := []byte("d8:announce…fake torrent bytes")
|
||||||
|
|
||||||
|
d := &Download{SourceType: SourceTorrent, SourceRef: "Some.Release", State: StateCatched}
|
||||||
|
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(1)}, blob)
|
||||||
|
if err != nil || existing != nil {
|
||||||
|
t.Fatalf("create: err=%v existing=%v", err, existing)
|
||||||
|
}
|
||||||
|
got, err := st.GetTorrentData(ctx, d.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != string(blob) {
|
||||||
|
t.Errorf("blob = %q, want %q", got, blob)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// При дедупе на активную задачу байты не пишутся (второй приём того же хеша).
|
||||||
|
func TestTorrentBlobNotWrittenOnDedup(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
first := &Download{SourceType: SourceTorrent, SourceRef: "A", State: StateCatched}
|
||||||
|
if _, err := st.CreateDownloadIfNoActive(ctx, first, []string{hashN(2)}, []byte("first")); err != nil {
|
||||||
|
t.Fatalf("create first: %v", err)
|
||||||
|
}
|
||||||
|
// Второй приём того же инфохэша — дедуп; его байты писаться не должны.
|
||||||
|
second := &Download{SourceType: SourceTorrent, SourceRef: "B", State: StateCatched}
|
||||||
|
existing, err := st.CreateDownloadIfNoActive(ctx, second, []string{hashN(2)}, []byte("second"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create second: %v", err)
|
||||||
|
}
|
||||||
|
if existing == nil || existing.ID != first.ID {
|
||||||
|
t.Fatalf("ожидался дедуп на первую задачу, got %v", existing)
|
||||||
|
}
|
||||||
|
got, err := st.GetTorrentData(ctx, first.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get first: %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != "first" {
|
||||||
|
t.Errorf("байты первой задачи перезаписаны: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нет байтов (magnet-источник) → ErrNotFound.
|
||||||
|
func TestTorrentBlobMissing(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
d := &Download{SourceType: SourceMagnet, SourceRef: "magnet:?xt=urn:btih:" + hashN(3), State: StateCatched}
|
||||||
|
if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(3)}, nil); err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.GetTorrentData(ctx, d.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("ожидался ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ func seedState(t *testing.T, st *Store, ih string, state State) string {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
d := &Download{SourceType: SourceMagnet, SourceRef: "magnet:?xt=urn:btih:" + ih, State: StateCatched}
|
d := &Download{SourceType: SourceMagnet, SourceRef: "magnet:?xt=urn:btih:" + ih, State: StateCatched}
|
||||||
if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}); err != nil {
|
if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}, nil); err != nil {
|
||||||
t.Fatalf("seed create: %v", err)
|
t.Fatalf("seed create: %v", err)
|
||||||
}
|
}
|
||||||
if state != StateCatched {
|
if state != StateCatched {
|
||||||
|
|||||||
+120
-12
@@ -2,10 +2,15 @@ package tgbot
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
|
|
||||||
@@ -21,6 +26,9 @@ type teleAPI interface {
|
|||||||
Request(c tgbotapi.Chattable) (*tgbotapi.APIResponse, error)
|
Request(c tgbotapi.Chattable) (*tgbotapi.APIResponse, error)
|
||||||
GetUpdatesChan(config tgbotapi.UpdateConfig) tgbotapi.UpdatesChannel
|
GetUpdatesChan(config tgbotapi.UpdateConfig) tgbotapi.UpdatesChannel
|
||||||
StopReceivingUpdates()
|
StopReceivingUpdates()
|
||||||
|
// GetFileDirectURL — прямой URL файла Telegram по file_id (для скачивания
|
||||||
|
// присланного .torrent-документа).
|
||||||
|
GetFileDirectURL(fileID string) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ingestor принимает загрузку (ingest.Service).
|
// Ingestor принимает загрузку (ingest.Service).
|
||||||
@@ -56,6 +64,8 @@ type Bot struct {
|
|||||||
|
|
||||||
mu sync.Mutex // защищает pending
|
mu sync.Mutex // защищает pending
|
||||||
pending map[int64]string // chatID → downloadID, ждущий подсказку
|
pending map[int64]string // chatID → downloadID, ждущий подсказку
|
||||||
|
|
||||||
|
httpClient *http.Client // для скачивания .torrent-документов Telegram
|
||||||
}
|
}
|
||||||
|
|
||||||
// New собирает бота поверх клиента Telegram.
|
// New собирает бота поверх клиента Telegram.
|
||||||
@@ -65,13 +75,14 @@ func New(client teleAPI, ing Ingestor, rev Reviewer, cfg Config, log *slog.Logge
|
|||||||
allowed[id] = true
|
allowed[id] = true
|
||||||
}
|
}
|
||||||
return &Bot{
|
return &Bot{
|
||||||
api: client,
|
api: client,
|
||||||
ingestor: ing,
|
ingestor: ing,
|
||||||
reviewer: rev,
|
reviewer: rev,
|
||||||
allowed: allowed,
|
allowed: allowed,
|
||||||
webBase: strings.TrimRight(cfg.WebBaseURL, "/"),
|
webBase: strings.TrimRight(cfg.WebBaseURL, "/"),
|
||||||
log: log,
|
log: log,
|
||||||
pending: map[int64]string{},
|
pending: map[int64]string{},
|
||||||
|
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +132,15 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
|
|||||||
b.send(m.Chat.ID, "Доступ запрещён.", nil)
|
b.send(m.Chat.ID, "Доступ запрещён.", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Документ (.torrent) — до ветки pending/текста: у документа m.Text пуст,
|
||||||
|
// иначе pending-подсказка «съела» бы его как пустую строку. Контекст — из
|
||||||
|
// подписи (m.Caption).
|
||||||
|
if m.Document != nil {
|
||||||
|
b.handleDocument(ctx, m)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
text := strings.TrimSpace(m.Text)
|
text := strings.TrimSpace(m.Text)
|
||||||
|
|
||||||
// Ждём подсказку для перераспознавания?
|
// Ждём подсказку для перераспознавания?
|
||||||
@@ -140,25 +160,113 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
|
|||||||
|
|
||||||
source, context, ok := ParseMessage(text)
|
source, context, ok := ParseMessage(text)
|
||||||
if !ok {
|
if !ok {
|
||||||
b.send(m.Chat.ID, "Не вижу magnet-ссылки. Перешлите сообщение торрент-бота или пришлите magnet.", nil)
|
b.send(m.Chat.ID, "Не вижу magnet-ссылки. Перешлите сообщение торрент-бота, пришлите magnet или .torrent-файл.", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res, err := b.ingestor.Ingest(ctx, ingest.Request{Source: source, Context: context})
|
b.ingestAndReply(ctx, m.Chat.ID, ingest.Request{Source: source, Context: context})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDocument принимает присланный .torrent-документ: проверяет тип/размер,
|
||||||
|
// скачивает байты через Bot API и подаёт в приём (подпись — контекстом).
|
||||||
|
func (b *Bot) handleDocument(ctx context.Context, m *tgbotapi.Message) {
|
||||||
|
doc := m.Document
|
||||||
|
if !isTorrentDoc(doc) {
|
||||||
|
b.send(m.Chat.ID, "Это не .torrent-файл. Пришлите magnet-ссылку или .torrent.", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if doc.FileSize > 0 && doc.FileSize > ingest.MaxTorrentSize {
|
||||||
|
b.send(m.Chat.ID, "Файл слишком большой для .torrent.", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := b.downloadFile(ctx, doc.FileID)
|
||||||
|
if err != nil {
|
||||||
|
b.log.Warn("telegram document download failed", "error", err)
|
||||||
|
b.send(m.Chat.ID, "Не удалось скачать файл из Telegram, попробуйте ещё раз.", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.ingestAndReply(ctx, m.Chat.ID, ingest.Request{
|
||||||
|
TorrentData: data,
|
||||||
|
TorrentName: doc.FileName, // фолбек для source_ref, если у раздачи нет имени
|
||||||
|
Context: m.Caption,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripURL убирает URL из ошибки *url.Error (URL файла Telegram содержит токен
|
||||||
|
// бота), оставляя только первопричину — защита от утечки секрета в логи.
|
||||||
|
func stripURL(err error) error {
|
||||||
|
var ue *url.Error
|
||||||
|
if errors.As(err, &ue) {
|
||||||
|
return ue.Err
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTorrentDoc — документ выглядит как .torrent (по mime или расширению).
|
||||||
|
func isTorrentDoc(doc *tgbotapi.Document) bool {
|
||||||
|
if doc == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if doc.MimeType == "application/x-bittorrent" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.HasSuffix(strings.ToLower(doc.FileName), ".torrent")
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadFile скачивает файл Telegram по file_id (прямой URL + HTTP GET),
|
||||||
|
// ограничивая объём MaxTorrentSize.
|
||||||
|
//
|
||||||
|
// ВАЖНО: прямой URL файла Telegram содержит токен бота
|
||||||
|
// (…/file/bot<TOKEN>/<path>). Ошибки транспорта (*url.Error) встраивают этот
|
||||||
|
// URL в текст — их нельзя возвращать/логировать как есть. stripURL оставляет
|
||||||
|
// только первопричину без URL, чтобы токен не утёк в логи (см. logging.md).
|
||||||
|
func (b *Bot) downloadFile(ctx context.Context, fileID string) ([]byte, error) {
|
||||||
|
fileURL, err := b.api.GetFileDirectURL(fileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("file url: %w", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("telegram file request: %w", stripURL(err))
|
||||||
|
}
|
||||||
|
resp, err := b.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("telegram file GET: %w", stripURL(err))
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("telegram file GET: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// LimitReader на 1 байт больше лимита — чтобы отличить «ровно лимит» от
|
||||||
|
// «больше лимита».
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, ingest.MaxTorrentSize+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(data) > ingest.MaxTorrentSize {
|
||||||
|
return nil, fmt.Errorf("telegram file exceeds %d bytes", ingest.MaxTorrentSize)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ingestAndReply принимает загрузку и отвечает пользователю единообразно для
|
||||||
|
// всех источников (текст/magnet и .torrent-документ).
|
||||||
|
func (b *Bot) ingestAndReply(ctx context.Context, chatID int64, req ingest.Request) {
|
||||||
|
res, err := b.ingestor.Ingest(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// res.DownloadID непуст, если сбой после создания задачи (напр. qbit);
|
// res.DownloadID непуст, если сбой после создания задачи (напр. qbit);
|
||||||
// при раннем разборе источника id ещё нет — даём дружелюбный текст без
|
// при раннем разборе источника id ещё нет — даём дружелюбный текст без
|
||||||
// него (детали всё равно в логах на доменной границе).
|
// него (детали всё равно в логах на доменной границе).
|
||||||
b.send(m.Chat.ID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
|
b.send(chatID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msg := fmt.Sprintf("Принято #%s — добавляю в qBittorrent.", res.DownloadID)
|
msg := fmt.Sprintf("Принято #%s — добавляю в qBittorrent.", res.DownloadID)
|
||||||
if res.Deduplicated {
|
if res.Deduplicated {
|
||||||
msg = fmt.Sprintf("Уже в работе #%s.", res.DownloadID)
|
msg = fmt.Sprintf("Уже в работе #%s.", res.DownloadID)
|
||||||
}
|
}
|
||||||
b.send(m.Chat.ID, msg+"\nПозову, когда нужно подтверждение.", nil)
|
b.send(chatID, msg+"\nПозову, когда нужно подтверждение.", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
const helpText = `jellybit-бот: пришлите magnet-ссылку или перешлите сообщение торрент-бота — поставлю на закачку.
|
const helpText = `jellybit-бот: пришлите magnet-ссылку, .torrent-файл или перешлите сообщение торрент-бота — поставлю на закачку.
|
||||||
Когда раздача скачается и потребуется подтверждение раскладки, позову кнопками.`
|
Когда раздача скачается и потребуется подтверждение раскладки, позову кнопками.`
|
||||||
|
|
||||||
// --- Колбэки (кнопки) ---
|
// --- Колбэки (кнопки) ---
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ type fakeAPI struct {
|
|||||||
sent []sentMsg
|
sent []sentMsg
|
||||||
edits []sentMsg
|
edits []sentMsg
|
||||||
answers []string
|
answers []string
|
||||||
|
fileURL string // GetFileDirectURL возвращает это (для .torrent-документов)
|
||||||
|
fileErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
type sentMsg struct {
|
type sentMsg struct {
|
||||||
@@ -47,6 +49,7 @@ func (f *fakeAPI) Request(c tgbotapi.Chattable) (*tgbotapi.APIResponse, error) {
|
|||||||
}
|
}
|
||||||
func (f *fakeAPI) GetUpdatesChan(tgbotapi.UpdateConfig) tgbotapi.UpdatesChannel { return nil }
|
func (f *fakeAPI) GetUpdatesChan(tgbotapi.UpdateConfig) tgbotapi.UpdatesChannel { return nil }
|
||||||
func (f *fakeAPI) StopReceivingUpdates() {}
|
func (f *fakeAPI) StopReceivingUpdates() {}
|
||||||
|
func (f *fakeAPI) GetFileDirectURL(string) (string, error) { return f.fileURL, f.fileErr }
|
||||||
|
|
||||||
type fakeIngestor struct {
|
type fakeIngestor struct {
|
||||||
lastReq ingest.Request
|
lastReq ingest.Request
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package tgbot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func docMsg(userID int64, doc *tgbotapi.Document, caption string) *tgbotapi.Message {
|
||||||
|
return &tgbotapi.Message{
|
||||||
|
MessageID: 1, From: &tgbotapi.User{ID: userID}, Chat: &tgbotapi.Chat{ID: userID},
|
||||||
|
Document: doc, Caption: caption,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// .torrent-документ: скачиваем байты и подаём в приём (подпись — контекстом).
|
||||||
|
func TestBot_IngestFromDocument(t *testing.T) {
|
||||||
|
torrentBytes := []byte("d8:announce…bytes")
|
||||||
|
fileSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write(torrentBytes)
|
||||||
|
}))
|
||||||
|
defer fileSrv.Close()
|
||||||
|
|
||||||
|
b, api, ing, _ := newTestBot(t, []int64{7})
|
||||||
|
api.fileURL = fileSrv.URL + "/file.torrent"
|
||||||
|
|
||||||
|
doc := &tgbotapi.Document{FileID: "abc", FileName: "dune.torrent", MimeType: "application/x-bittorrent"}
|
||||||
|
b.handleMessage(context.Background(), docMsg(7, doc, "Дюна 2"))
|
||||||
|
|
||||||
|
if string(ing.lastReq.TorrentData) != string(torrentBytes) {
|
||||||
|
t.Errorf("TorrentData = %q, want %q", ing.lastReq.TorrentData, torrentBytes)
|
||||||
|
}
|
||||||
|
if ing.lastReq.Context != "Дюна 2" {
|
||||||
|
t.Errorf("context (подпись) = %q", ing.lastReq.Context)
|
||||||
|
}
|
||||||
|
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #"+tid) {
|
||||||
|
t.Errorf("sent = %+v", api.sent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Документ по расширению .torrent (mime может отсутствовать) тоже принимается.
|
||||||
|
func TestBot_DocumentByExtension(t *testing.T) {
|
||||||
|
fileSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("bytes"))
|
||||||
|
}))
|
||||||
|
defer fileSrv.Close()
|
||||||
|
|
||||||
|
b, api, ing, _ := newTestBot(t, []int64{7})
|
||||||
|
api.fileURL = fileSrv.URL
|
||||||
|
|
||||||
|
doc := &tgbotapi.Document{FileID: "x", FileName: "Release.TORRENT"} // без mime, регистр
|
||||||
|
b.handleMessage(context.Background(), docMsg(7, doc, ""))
|
||||||
|
|
||||||
|
if len(ing.lastReq.TorrentData) == 0 {
|
||||||
|
t.Error("документ .torrent по расширению не принят")
|
||||||
|
}
|
||||||
|
_ = api
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ошибка скачивания не должна утекать токен бота (URL файла Telegram содержит
|
||||||
|
// …/bot<TOKEN>/…). Транспортная ошибка *url.Error встраивает URL — проверяем,
|
||||||
|
// что stripURL его убрал.
|
||||||
|
func TestBot_DownloadErrorNoTokenLeak(t *testing.T) {
|
||||||
|
b, api, _, _ := newTestBot(t, []int64{7})
|
||||||
|
// «Токен» в URL, указывающем на закрытый порт → ошибка транспорта.
|
||||||
|
api.fileURL = "http://127.0.0.1:1/file/botSECRET123:AAtoken/x"
|
||||||
|
_, err := b.downloadFile(context.Background(), "fid")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ожидалась ошибка транспорта")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "SECRET123") || strings.Contains(err.Error(), "AAtoken") {
|
||||||
|
t.Errorf("токен утёк в текст ошибки: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Не-.torrent документ → подсказка, приёма нет.
|
||||||
|
func TestBot_NonTorrentDocumentRejected(t *testing.T) {
|
||||||
|
b, api, ing, _ := newTestBot(t, []int64{7})
|
||||||
|
doc := &tgbotapi.Document{FileID: "x", FileName: "photo.jpg", MimeType: "image/jpeg"}
|
||||||
|
b.handleMessage(context.Background(), docMsg(7, doc, ""))
|
||||||
|
|
||||||
|
if len(ing.lastReq.TorrentData) != 0 || ing.lastReq.Source != "" {
|
||||||
|
t.Errorf("не-.torrent не должен идти в приём: %+v", ing.lastReq)
|
||||||
|
}
|
||||||
|
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "не .torrent") {
|
||||||
|
t.Errorf("нет подсказки об ошибке: %+v", api.sent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Документ приходит ДО ветки pending-подсказки: ожидающая refine-подсказка не
|
||||||
|
// «съедает» документ (у документа m.Text пуст).
|
||||||
|
func TestBot_DocumentBeforePendingHint(t *testing.T) {
|
||||||
|
fileSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("bytes"))
|
||||||
|
}))
|
||||||
|
defer fileSrv.Close()
|
||||||
|
|
||||||
|
b, api, ing, rev := newTestBot(t, []int64{7})
|
||||||
|
api.fileURL = fileSrv.URL
|
||||||
|
b.setPending(7, tid) // ждём подсказку для перераспознавания
|
||||||
|
|
||||||
|
doc := &tgbotapi.Document{FileID: "x", FileName: "a.torrent", MimeType: "application/x-bittorrent"}
|
||||||
|
b.handleMessage(context.Background(), docMsg(7, doc, ""))
|
||||||
|
|
||||||
|
if len(rev.refined) != 0 {
|
||||||
|
t.Errorf("документ ошибочно принят как refine-подсказка: %v", rev.refined)
|
||||||
|
}
|
||||||
|
if len(ing.lastReq.TorrentData) == 0 {
|
||||||
|
t.Error("документ не принят как .torrent")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
// Package torrent разбирает `.torrent`-файлы (bencode): извлекает инфохэш(и) и
|
||||||
|
// метаданные раздачи для приёма загрузки байтами.
|
||||||
|
//
|
||||||
|
// Инфохэши нормализуются к нижнему hex — в этом же виде их отдаёт qBittorrent
|
||||||
|
// (поля hash/infohash_v1/infohash_v2), что позволяет сопоставлять задачи с
|
||||||
|
// раздачами при поллинге. v1-хеш берётся как SHA1 ИСХОДНЫХ байтов info-словаря
|
||||||
|
// (библиотека хранит их и не переэнкодит), иначе хеш разошёлся бы с qBittorrent.
|
||||||
|
package torrent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/anacrolix/torrent/metainfo"
|
||||||
|
infohashv2 "github.com/anacrolix/torrent/types/infohash-v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotTorrent возвращается, если байты не разбираются как `.torrent`.
|
||||||
|
var ErrNotTorrent = errors.New("not a torrent file")
|
||||||
|
|
||||||
|
// File — один файл раздачи.
|
||||||
|
type File struct {
|
||||||
|
Path string // относительный путь ("dir/file.mkv")
|
||||||
|
Length int64 // размер в байтах
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info — разобранный `.torrent`.
|
||||||
|
type Info struct {
|
||||||
|
Infohash string // первичный хеш: v1 приоритетно (нижний hex, 40 для v1, 64 для v2)
|
||||||
|
Infohashes []string // все хеши файла (гибрид несёт v1 и v2); v1 раньше v2
|
||||||
|
DisplayName string // имя раздачи (info.name)
|
||||||
|
Files []File // файлы раздачи (для одиночного — один элемент)
|
||||||
|
TotalLength int64 // суммарный размер
|
||||||
|
Trackers []string // announce + announce-list (плоско, без дублей)
|
||||||
|
Comment string // comment, если задан
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse разбирает байты `.torrent`. Извлекает те инфохэши, которые файл реально
|
||||||
|
// несёт: v1 (для v1/гибрида) и v2 (для v2/гибрида, BEP52) — каждый в том виде,
|
||||||
|
// в каком его сообщает qBittorrent. Байты info не переэнкодятся.
|
||||||
|
func Parse(data []byte) (Info, error) {
|
||||||
|
mi, err := metainfo.Load(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return Info{}, fmt.Errorf("%w: %v", ErrNotTorrent, err)
|
||||||
|
}
|
||||||
|
meta, err := mi.UnmarshalInfo()
|
||||||
|
if err != nil {
|
||||||
|
return Info{}, fmt.Errorf("%w: info: %v", ErrNotTorrent, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hashes []string
|
||||||
|
if meta.HasV1() {
|
||||||
|
hashes = append(hashes, strings.ToLower(mi.HashInfoBytes().HexString()))
|
||||||
|
}
|
||||||
|
if meta.HasV2() {
|
||||||
|
v2 := infohashv2.HashBytes(mi.InfoBytes)
|
||||||
|
hashes = append(hashes, strings.ToLower(v2.HexString()))
|
||||||
|
}
|
||||||
|
if len(hashes) == 0 {
|
||||||
|
return Info{}, fmt.Errorf("%w: no usable infohash", ErrNotTorrent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fs, total := files(&meta)
|
||||||
|
return Info{
|
||||||
|
Infohash: hashes[0],
|
||||||
|
Infohashes: hashes,
|
||||||
|
DisplayName: meta.BestName(),
|
||||||
|
Files: fs,
|
||||||
|
TotalLength: total,
|
||||||
|
Trackers: mi.UpvertedAnnounceList().DistinctValues(),
|
||||||
|
Comment: mi.Comment,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// files собирает файлы раздачи и суммарный размер из info (одиночный файл или
|
||||||
|
// дерево). Байты .torrent — недоверенный вход: на кривых/вырожденных раздачах
|
||||||
|
// библиотека (UpvertedFiles/FileTree) способна паниковать (напр. делением на
|
||||||
|
// ноль при piece length 0). Дерево файлов — лишь контекст-сигнал, не критично,
|
||||||
|
// поэтому паники гасим: инфохэш и имя уже извлечены, приём не валим.
|
||||||
|
func files(meta *metainfo.Info) (out []File, total int64) {
|
||||||
|
defer func() { _ = recover() }()
|
||||||
|
for _, fi := range meta.UpvertedFiles() {
|
||||||
|
seg := fi.Path
|
||||||
|
if len(seg) == 0 {
|
||||||
|
seg = fi.PathUtf8
|
||||||
|
}
|
||||||
|
p := path.Join(seg...)
|
||||||
|
if p == "" {
|
||||||
|
p = meta.BestName()
|
||||||
|
}
|
||||||
|
out = append(out, File{Path: p, Length: fi.Length})
|
||||||
|
total += fi.Length
|
||||||
|
}
|
||||||
|
return out, total
|
||||||
|
}
|
||||||
|
|
||||||
|
// announceLabelRe — служебные поддомены анонса трекера, которые убираем из
|
||||||
|
// домена происхождения ради чистого сигнала (bt.t-ru.org → t-ru.org).
|
||||||
|
var announceLabelRe = regexp.MustCompile(`^(?:bt\d*|www|announce|tracker|open)\.`)
|
||||||
|
|
||||||
|
// Context синтезирует человекочитаемый контекст распознавания из полей файла
|
||||||
|
// (без сети): имя раздачи, суммарный размер, сигнал по дереву файлов, домен
|
||||||
|
// трекера, комментарий. Строки-факты через "\n"; пустая строка — если полей
|
||||||
|
// нет. Предназначен для download.Context (recognition, веб-UI), но НЕ для
|
||||||
|
// вывода отображаемого имени.
|
||||||
|
func (i Info) Context() string {
|
||||||
|
var lines []string
|
||||||
|
|
||||||
|
if name := strings.TrimSpace(i.DisplayName); name != "" {
|
||||||
|
lines = append(lines, name)
|
||||||
|
}
|
||||||
|
if i.TotalLength > 0 {
|
||||||
|
lines = append(lines, "Размер: "+humanSize(i.TotalLength))
|
||||||
|
}
|
||||||
|
if s := filesSignal(i.Files); s != "" {
|
||||||
|
lines = append(lines, s)
|
||||||
|
}
|
||||||
|
if d := originDomains(i.Trackers); d != "" {
|
||||||
|
lines = append(lines, "Трекер: "+d)
|
||||||
|
}
|
||||||
|
if c := strings.TrimSpace(i.Comment); c != "" {
|
||||||
|
lines = append(lines, "Комментарий: "+oneLine(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// filesSignal — сигнал по дереву файлов для recognition: число файлов и
|
||||||
|
// характерные расширения (много видеофайлов → вероятно сериал). Для одиночного
|
||||||
|
// файла ничего не добавляет (имя раздачи уже есть строкой названия).
|
||||||
|
func filesSignal(files []File) string {
|
||||||
|
if len(files) <= 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var exts []string
|
||||||
|
for _, f := range files {
|
||||||
|
ext := strings.ToLower(strings.TrimPrefix(path.Ext(f.Path), "."))
|
||||||
|
if ext == "" || seen[ext] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[ext] = true
|
||||||
|
if len(exts) < 5 {
|
||||||
|
exts = append(exts, ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s := fmt.Sprintf("Файлов: %d", len(files))
|
||||||
|
if len(exts) > 0 {
|
||||||
|
s += " (" + strings.Join(exts, ", ") + ")"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// humanSize форматирует размер в байтах человекочитаемо (≈ 2.1 GiB).
|
||||||
|
func humanSize(n int64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if n < unit {
|
||||||
|
return fmt.Sprintf("%d B", n)
|
||||||
|
}
|
||||||
|
f := float64(n)
|
||||||
|
i := -1
|
||||||
|
for _, u := range []string{"KiB", "MiB", "GiB", "TiB", "PiB"} {
|
||||||
|
f /= unit
|
||||||
|
i++
|
||||||
|
if f < unit {
|
||||||
|
return fmt.Sprintf("≈ %.1f %s", f, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("≈ %.1f PiB", f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// originDomains извлекает уникальные домены происхождения из URL трекеров
|
||||||
|
// (http(s)/udp с хостом), убирая служебные поддомены анонса. Порядок
|
||||||
|
// сохраняется; результат — через ", ".
|
||||||
|
func originDomains(trackers []string) string {
|
||||||
|
var out []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, ref := range trackers {
|
||||||
|
u, err := url.Parse(strings.TrimSpace(ref))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch strings.ToLower(u.Scheme) {
|
||||||
|
case "http", "https", "udp":
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
host := strings.ToLower(u.Hostname())
|
||||||
|
if host == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s := announceLabelRe.ReplaceAllString(host, ""); strings.Contains(s, ".") {
|
||||||
|
host = s
|
||||||
|
}
|
||||||
|
if seen[host] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[host] = true
|
||||||
|
out = append(out, host)
|
||||||
|
}
|
||||||
|
return strings.Join(out, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// oneLine схлопывает переводы строк в пробелы (комментарий бывает многострочным).
|
||||||
|
func oneLine(s string) string {
|
||||||
|
return strings.Join(strings.Fields(s), " ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package torrent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/anacrolix/torrent/bencode"
|
||||||
|
"github.com/anacrolix/torrent/metainfo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeTorrent оборачивает готовые info-байты в metainfo и возвращает байты
|
||||||
|
// .torrent (announce задаёт трекер). Позволяет строить v2/гибридные фикстуры,
|
||||||
|
// у которых info собран вручную.
|
||||||
|
func writeTorrent(t *testing.T, infoBytes []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: "http://t/ann"}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := mi.Write(&buf); err != nil {
|
||||||
|
t.Fatalf("write metainfo: %v", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// v2-only торрент (meta version 2, без v1-полей) сохраняется под v2-хешем
|
||||||
|
// (64-hex = SHA256 исходных байтов info) — как его отдаёт qBittorrent.
|
||||||
|
func TestParseV2OnlyHash(t *testing.T) {
|
||||||
|
// Без Files/Length/Pieces + meta version 2 → HasV1()=false, HasV2()=true.
|
||||||
|
info := metainfo.Info{Name: "V2.Only.Release", MetaVersion: 2}
|
||||||
|
infoBytes, err := bencode.Marshal(info)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(infoBytes)
|
||||||
|
wantV2 := hex.EncodeToString(sum[:])
|
||||||
|
|
||||||
|
got, err := Parse(writeTorrent(t, infoBytes))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(got.Infohashes) != 1 {
|
||||||
|
t.Fatalf("infohashes = %v, want один v2-хеш", got.Infohashes)
|
||||||
|
}
|
||||||
|
if got.Infohash != wantV2 {
|
||||||
|
t.Errorf("infohash = %q, want v2 %q", got.Infohash, wantV2)
|
||||||
|
}
|
||||||
|
if len(got.Infohash) != 64 {
|
||||||
|
t.Errorf("длина хеша = %d, want 64 (v2)", len(got.Infohash))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Гибрид (v1 + v2): оба хеша, v1 (40-hex) первым, затем v2 (64-hex).
|
||||||
|
func TestParseHybridHashes(t *testing.T) {
|
||||||
|
info := metainfo.Info{
|
||||||
|
Name: "Hybrid", Length: 100, PieceLength: 1024,
|
||||||
|
Pieces: make([]byte, 20), MetaVersion: 2,
|
||||||
|
}
|
||||||
|
infoBytes, err := bencode.Marshal(info)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
sha1sum := sha1.Sum(infoBytes)
|
||||||
|
sha256sum := sha256.Sum256(infoBytes)
|
||||||
|
wantV1 := hex.EncodeToString(sha1sum[:])
|
||||||
|
wantV2 := hex.EncodeToString(sha256sum[:])
|
||||||
|
|
||||||
|
got, err := Parse(writeTorrent(t, infoBytes))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(got.Infohashes) != 2 {
|
||||||
|
t.Fatalf("infohashes = %v, want [v1, v2]", got.Infohashes)
|
||||||
|
}
|
||||||
|
if got.Infohashes[0] != wantV1 || got.Infohashes[1] != wantV2 {
|
||||||
|
t.Errorf("infohashes = %v, want [%s, %s]", got.Infohashes, wantV1, wantV2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// build собирает валидные байты `.torrent` из info и метаданных, возвращая их
|
||||||
|
// вместе с ожидаемым v1-инфохэшем (SHA1 исходных байтов info) — так тест
|
||||||
|
// проверяет извлечение, не переизобретая хеширование.
|
||||||
|
func build(t *testing.T, info metainfo.Info, announce string, comment string) (data []byte, wantV1 string) {
|
||||||
|
t.Helper()
|
||||||
|
infoBytes, err := bencode.Marshal(info)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal info: %v", err)
|
||||||
|
}
|
||||||
|
sum := sha1.Sum(infoBytes)
|
||||||
|
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: announce, Comment: comment}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := mi.Write(&buf); err != nil {
|
||||||
|
t.Fatalf("write metainfo: %v", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes(), hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1-piece заглушка (20 байт) — присутствие Pieces делает info v1.
|
||||||
|
func pieces(n int) []byte { return make([]byte, 20*n) }
|
||||||
|
|
||||||
|
func TestParseSingleFile(t *testing.T) {
|
||||||
|
data, wantV1 := build(t, metainfo.Info{
|
||||||
|
Name: "Dune.Part.Two.2024.2160p.mkv",
|
||||||
|
Length: 2100,
|
||||||
|
PieceLength: 1024,
|
||||||
|
Pieces: pieces(3),
|
||||||
|
}, "http://bt.rutracker.org/announce", "")
|
||||||
|
|
||||||
|
info, err := Parse(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if info.Infohash != wantV1 {
|
||||||
|
t.Errorf("infohash = %q, want %q", info.Infohash, wantV1)
|
||||||
|
}
|
||||||
|
if len(info.Infohashes) != 1 || info.Infohashes[0] != wantV1 {
|
||||||
|
t.Errorf("infohashes = %v, want [%s]", info.Infohashes, wantV1)
|
||||||
|
}
|
||||||
|
if info.DisplayName != "Dune.Part.Two.2024.2160p.mkv" {
|
||||||
|
t.Errorf("name = %q", info.DisplayName)
|
||||||
|
}
|
||||||
|
if info.TotalLength != 2100 {
|
||||||
|
t.Errorf("total = %d, want 2100", info.TotalLength)
|
||||||
|
}
|
||||||
|
if len(info.Files) != 1 {
|
||||||
|
t.Errorf("files = %v, want 1", info.Files)
|
||||||
|
}
|
||||||
|
ctx := info.Context()
|
||||||
|
if !strings.Contains(ctx, "Dune.Part.Two") {
|
||||||
|
t.Errorf("context без имени: %q", ctx)
|
||||||
|
}
|
||||||
|
if !strings.Contains(ctx, "Размер:") {
|
||||||
|
t.Errorf("context без размера: %q", ctx)
|
||||||
|
}
|
||||||
|
if !strings.Contains(ctx, "rutracker.org") {
|
||||||
|
t.Errorf("context без домена трекера: %q", ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMultiFile(t *testing.T) {
|
||||||
|
data, _ := build(t, metainfo.Info{
|
||||||
|
Name: "Fargo.S01",
|
||||||
|
PieceLength: 1024,
|
||||||
|
Pieces: pieces(5),
|
||||||
|
Files: []metainfo.FileInfo{
|
||||||
|
{Length: 700, Path: []string{"Fargo.S01E01.mkv"}},
|
||||||
|
{Length: 800, Path: []string{"Fargo.S01E02.mkv"}},
|
||||||
|
{Length: 5, Path: []string{"subs", "e01.srt"}},
|
||||||
|
},
|
||||||
|
}, "udp://tracker.example.com:80", "")
|
||||||
|
|
||||||
|
info, err := Parse(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(info.Files) != 3 {
|
||||||
|
t.Fatalf("files = %d, want 3", len(info.Files))
|
||||||
|
}
|
||||||
|
if info.TotalLength != 1505 {
|
||||||
|
t.Errorf("total = %d, want 1505", info.TotalLength)
|
||||||
|
}
|
||||||
|
// путь из сегментов через "/"
|
||||||
|
if info.Files[2].Path != "subs/e01.srt" {
|
||||||
|
t.Errorf("path = %q, want subs/e01.srt", info.Files[2].Path)
|
||||||
|
}
|
||||||
|
ctx := info.Context()
|
||||||
|
if !strings.Contains(ctx, "Файлов: 3") {
|
||||||
|
t.Errorf("нет сигнала файлов: %q", ctx)
|
||||||
|
}
|
||||||
|
if !strings.Contains(ctx, "mkv") || !strings.Contains(ctx, "srt") {
|
||||||
|
t.Errorf("нет расширений в сигнале: %q", ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTrackersDedupAndComment(t *testing.T) {
|
||||||
|
data, _ := build(t, metainfo.Info{
|
||||||
|
Name: "X",
|
||||||
|
Length: 10,
|
||||||
|
PieceLength: 1024,
|
||||||
|
Pieces: pieces(1),
|
||||||
|
}, "http://bt2.t-ru.org/ann", "Многострочный\nкомментарий")
|
||||||
|
|
||||||
|
info, err := Parse(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(info.Trackers) == 0 {
|
||||||
|
t.Fatal("нет трекеров")
|
||||||
|
}
|
||||||
|
ctx := info.Context()
|
||||||
|
// служебный поддомен bt2. срезается
|
||||||
|
if !strings.Contains(ctx, "t-ru.org") {
|
||||||
|
t.Errorf("домен не нормализован: %q", ctx)
|
||||||
|
}
|
||||||
|
// комментарий схлопнут в одну строку
|
||||||
|
if !strings.Contains(ctx, "Комментарий: Многострочный комментарий") {
|
||||||
|
t.Errorf("комментарий: %q", ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInvalidBytes(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
data []byte
|
||||||
|
}{
|
||||||
|
{"empty", nil},
|
||||||
|
{"garbage", []byte("this is not a torrent")},
|
||||||
|
{"bencode-not-dict", []byte("i42e")},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if _, err := Parse(tc.data); err == nil {
|
||||||
|
t.Errorf("ожидалась ошибка для %s", tc.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContextEmptyWhenNoFields(t *testing.T) {
|
||||||
|
// info без имени (NoName-сентинел), без трекера/комментария: строк-фактов
|
||||||
|
// минимум. Проверяем, что Context не паникует и не тянет сеть (чистая
|
||||||
|
// функция — сетевых вызовов в коде нет по построению).
|
||||||
|
data, _ := build(t, metainfo.Info{
|
||||||
|
Name: "-", // metainfo.NoName
|
||||||
|
Length: 0,
|
||||||
|
PieceLength: 1024,
|
||||||
|
Pieces: pieces(1),
|
||||||
|
}, "", "")
|
||||||
|
info, err := Parse(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
_ = info.Context() // не паникует; состав строк — по полям
|
||||||
|
}
|
||||||
@@ -55,7 +55,7 @@ func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
|
|||||||
DisplayName: t.Name, // усыновление: приёма/rename нет, берём имя торрента из qBittorrent
|
DisplayName: t.Name, // усыновление: приёма/rename нет, берём имя торрента из qBittorrent
|
||||||
State: store.StateDownloading,
|
State: store.StateDownloading,
|
||||||
}
|
}
|
||||||
existing, err := w.store.CreateDownloadIfNoActive(ctx, d, hashes)
|
existing, err := w.store.CreateDownloadIfNoActive(ctx, d, hashes, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.log.Error("discover adopt failed", "capability", capIngest, "infohash", hashes[0], "error", err)
|
w.log.Error("discover adopt failed", "capability", capIngest, "infohash", hashes[0], "error", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ type memStore struct {
|
|||||||
overrides map[string]map[string]string
|
overrides map[string]map[string]string
|
||||||
links []store.FileLink
|
links []store.FileLink
|
||||||
candidates []store.MetadataCandidate
|
candidates []store.MetadataCandidate
|
||||||
|
torrents map[string][]byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMemStore() *memStore {
|
func newMemStore() *memStore {
|
||||||
@@ -286,7 +287,7 @@ func (m *memStore) FindActiveByInfohash(_ context.Context, hashes ...string) (*s
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *memStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error) {
|
func (m *memStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error) {
|
||||||
if existing, _ := m.FindActiveByInfohash(ctx, hashes...); existing != nil {
|
if existing, _ := m.FindActiveByInfohash(ctx, hashes...); existing != nil {
|
||||||
return existing, nil
|
return existing, nil
|
||||||
}
|
}
|
||||||
@@ -298,11 +299,24 @@ func (m *memStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Downlo
|
|||||||
cp.Infohashes = append(cp.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
|
cp.Infohashes = append(cp.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
|
||||||
}
|
}
|
||||||
m.downloads[id] = &cp
|
m.downloads[id] = &cp
|
||||||
|
if len(torrentBlob) > 0 {
|
||||||
|
if m.torrents == nil {
|
||||||
|
m.torrents = map[string][]byte{}
|
||||||
|
}
|
||||||
|
m.torrents[id] = torrentBlob
|
||||||
|
}
|
||||||
d.ID = id
|
d.ID = id
|
||||||
d.Infohashes = cp.Infohashes
|
d.Infohashes = cp.Infohashes
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *memStore) GetTorrentData(_ context.Context, downloadID string) ([]byte, error) {
|
||||||
|
if data, ok := m.torrents[downloadID]; ok {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
return nil, store.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
func (m *memStore) ActivateIfNoOtherActive(ctx context.Context, id string, st store.State, code, msg string) error {
|
func (m *memStore) ActivateIfNoOtherActive(ctx context.Context, id string, st store.State, code, msg string) error {
|
||||||
d, ok := m.downloads[id]
|
d, ok := m.downloads[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package worker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/hex"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/anacrolix/torrent/bencode"
|
||||||
|
"github.com/anacrolix/torrent/metainfo"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildTorrent — валидные байты .torrent и их v1-инфохэш.
|
||||||
|
func buildTorrent(t *testing.T, name string) (data []byte, infohash string) {
|
||||||
|
t.Helper()
|
||||||
|
info := metainfo.Info{Name: name, Length: 2048, PieceLength: 1024, Pieces: make([]byte, 40)}
|
||||||
|
infoBytes, err := bencode.Marshal(info)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal info: %v", err)
|
||||||
|
}
|
||||||
|
sum := sha1.Sum(infoBytes)
|
||||||
|
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: "http://t/ann"}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := mi.Write(&buf); err != nil {
|
||||||
|
t.Fatalf("write metainfo: %v", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes(), hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// catchedTorrentStore — пойманная torrent-загрузка с сохранёнными байтами.
|
||||||
|
func catchedTorrentStore(id string, data []byte, infohash string) *fakeStore {
|
||||||
|
return &fakeStore{
|
||||||
|
downloads: map[string]*store.Download{
|
||||||
|
id: {
|
||||||
|
ID: id,
|
||||||
|
State: store.StateCatched,
|
||||||
|
SourceType: store.SourceTorrent,
|
||||||
|
SourceRef: "Some.Release.Name", // человекочитаемый референс, не URL
|
||||||
|
Infohashes: hashesOf(id, infohash),
|
||||||
|
CreatedAt: nowStr,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
torrents: map[string][]byte{id: data},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пойманная torrent-загрузка добавляется в qBittorrent ФАЙЛОМ (Torrents), не URL.
|
||||||
|
func TestProcessCatchedTorrentAddsAsFile(t *testing.T) {
|
||||||
|
data, ih := buildTorrent(t, "Dune.mkv")
|
||||||
|
st := catchedTorrentStore("1", data, ih)
|
||||||
|
qb := &fakeQbt{}
|
||||||
|
w := newTestWorker(st, qb)
|
||||||
|
w.SetNamer(&fakeNamer{name: "Дюна (2024)"})
|
||||||
|
|
||||||
|
w.processCatched(context.Background())
|
||||||
|
|
||||||
|
if len(qb.added) != 1 {
|
||||||
|
t.Fatalf("qbt.Add calls = %d, want 1", len(qb.added))
|
||||||
|
}
|
||||||
|
add := qb.added[0]
|
||||||
|
if len(add.URLs) != 0 {
|
||||||
|
t.Errorf("torrent не должен добавляться по URLs: %v", add.URLs)
|
||||||
|
}
|
||||||
|
if len(add.Torrents) != 1 || !bytes.Equal(add.Torrents[0], data) {
|
||||||
|
t.Errorf("байты .torrent не переданы в Add.Torrents")
|
||||||
|
}
|
||||||
|
if add.Rename != "Дюна (2024)" {
|
||||||
|
t.Errorf("rename = %q", add.Rename)
|
||||||
|
}
|
||||||
|
if st.downloads["1"].State != store.StateDownloading {
|
||||||
|
t.Errorf("state = %q, want downloading", st.downloads["1"].State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry torrent-загрузки без живой раздачи добавляет её ФАЙЛОМ (регрессия
|
||||||
|
// BLOCKER 1: раньше add гейтился на magnet и torrent активировался без раздачи).
|
||||||
|
func TestRetryTorrentAddsAsFile(t *testing.T) {
|
||||||
|
data, ih := buildTorrent(t, "Fargo.mkv")
|
||||||
|
st := catchedTorrentStore("1", data, ih)
|
||||||
|
st.downloads["1"].State = store.StateFailed
|
||||||
|
qb := &fakeQbt{} // раздачи в qBittorrent нет → должен добавить заново
|
||||||
|
w := newTestWorker(st, qb)
|
||||||
|
|
||||||
|
if err := w.Retry(context.Background(), "1"); err != nil {
|
||||||
|
t.Fatalf("Retry: %v", err)
|
||||||
|
}
|
||||||
|
if len(qb.added) != 1 {
|
||||||
|
t.Fatalf("ожидалось повторное добавление, got %d", len(qb.added))
|
||||||
|
}
|
||||||
|
if len(qb.added[0].Torrents) != 1 || len(qb.added[0].URLs) != 0 {
|
||||||
|
t.Errorf("retry torrent должен добавляться файлом, add = %+v", qb.added[0])
|
||||||
|
}
|
||||||
|
if st.downloads["1"].State != store.StateDownloading {
|
||||||
|
t.Errorf("state = %q, want downloading", st.downloads["1"].State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry torrent-загрузки без сохранённых байтов — откат активации (не оставляем
|
||||||
|
// «качающуюся» задачу без раздачи).
|
||||||
|
func TestRetryTorrentMissingBytesRollsBack(t *testing.T) {
|
||||||
|
_, ih := buildTorrent(t, "X.mkv")
|
||||||
|
st := catchedTorrentStore("1", nil, ih) // байтов нет
|
||||||
|
delete(st.torrents, "1")
|
||||||
|
st.downloads["1"].State = store.StateFailed
|
||||||
|
qb := &fakeQbt{}
|
||||||
|
w := newTestWorker(st, qb)
|
||||||
|
|
||||||
|
if err := w.Retry(context.Background(), "1"); err == nil {
|
||||||
|
t.Fatal("ожидалась ошибка (нет байтов torrent)")
|
||||||
|
}
|
||||||
|
if len(qb.added) != 0 {
|
||||||
|
t.Errorf("add не должен вызываться без байтов")
|
||||||
|
}
|
||||||
|
if st.downloads["1"].State != store.StateFailed {
|
||||||
|
t.Errorf("state = %q, want failed (откат активации)", st.downloads["1"].State)
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
-18
@@ -28,6 +28,7 @@ import (
|
|||||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/torrent"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Стадии (capability) — адресуют запись к подсистеме при корреляции по
|
// Стадии (capability) — адресуют запись к подсистеме при корреляции по
|
||||||
@@ -53,7 +54,10 @@ type Store interface {
|
|||||||
|
|
||||||
// Идентичность/инвариант «одна активная загрузка на infohash».
|
// Идентичность/инвариант «одна активная загрузка на infohash».
|
||||||
ExistsByInfohash(ctx context.Context, hashes ...string) (bool, error)
|
ExistsByInfohash(ctx context.Context, hashes ...string) (bool, error)
|
||||||
CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error)
|
CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error)
|
||||||
|
// GetTorrentData — сохранённые байты `.torrent` (для добавления раздачи
|
||||||
|
// файлом на шаге processCatched/Retry у source_type=torrent).
|
||||||
|
GetTorrentData(ctx context.Context, downloadID string) ([]byte, error)
|
||||||
ActivateIfNoOtherActive(ctx context.Context, id string, state store.State, errCode, errMsg string) error
|
ActivateIfNoOtherActive(ctx context.Context, id string, state store.State, errCode, errMsg string) error
|
||||||
AddInfohashes(ctx context.Context, downloadID string, hashes []string) error
|
AddInfohashes(ctx context.Context, downloadID string, hashes []string) error
|
||||||
|
|
||||||
@@ -352,21 +356,21 @@ func (w *Worker) processCatched(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вне w.mu: вывод имени (потенциально медленный LLM) и добавление.
|
// Вне w.mu: сбор параметров добавления по типу источника (для torrent —
|
||||||
|
// чтение байтов), вывод имени (потенциально медленный LLM) и добавление.
|
||||||
|
hint, addReq, prepErr := w.sourceAddParts(cctx, d)
|
||||||
|
if prepErr != nil {
|
||||||
|
// Байты torrent недоступны (не должно быть при штатном приёме) —
|
||||||
|
// остаёмся в catched, повтор на следующем тике.
|
||||||
|
logctx.From(cctx).Warn("catched prepare add failed, will retry", "error", prepErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
var rename string
|
var rename string
|
||||||
if w.namer != nil {
|
if w.namer != nil {
|
||||||
hint := ""
|
|
||||||
if info, perr := magnet.Parse(d.SourceRef); perr == nil {
|
|
||||||
hint = info.DisplayName
|
|
||||||
}
|
|
||||||
rename = w.namer.DeriveName(cctx, d.Context, hint)
|
rename = w.namer.DeriveName(cctx, d.Context, hint)
|
||||||
}
|
}
|
||||||
addErr := w.qbt.Add(cctx, qbt.AddRequest{
|
addReq.Rename = rename
|
||||||
URLs: []string{d.SourceRef},
|
addErr := w.qbt.Add(cctx, addReq)
|
||||||
Category: w.cfg.Category,
|
|
||||||
SavePath: w.cfg.SavePath,
|
|
||||||
Rename: rename,
|
|
||||||
})
|
|
||||||
if addErr != nil {
|
if addErr != nil {
|
||||||
// Транзиентный сбой (qBit недоступен) — остаёмся в catched, повтор на
|
// Транзиентный сбой (qBit недоступен) — остаёмся в catched, повтор на
|
||||||
// следующем тике. Поведение вызова qBit уже залогировал клиент (ext.*).
|
// следующем тике. Поведение вызова qBit уже залогировал клиент (ext.*).
|
||||||
@@ -400,6 +404,33 @@ func (w *Worker) catchedAge(d store.Download) (time.Duration, bool) {
|
|||||||
return w.now().Sub(created), true
|
return w.now().Sub(created), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sourceAddParts собирает параметры добавления раздачи в qBittorrent по типу
|
||||||
|
// источника и подсказку имени (hint) для namer. magnet/url — ссылкой (URLs),
|
||||||
|
// hint из полей ссылки; torrent — байтами файла (Torrents), hint из имени
|
||||||
|
// раздачи. Общий для обоих add-путей воркера (processCatched и Retry), чтобы
|
||||||
|
// диспетч по source_type был в одном месте. Rename вызывающий проставляет сам
|
||||||
|
// (после namer). Ошибку возвращает лишь torrent-ветка (байты недоступны).
|
||||||
|
func (w *Worker) sourceAddParts(ctx context.Context, d store.Download) (hint string, req qbt.AddRequest, err error) {
|
||||||
|
req = qbt.AddRequest{Category: w.cfg.Category, SavePath: w.cfg.SavePath}
|
||||||
|
if d.SourceType == store.SourceTorrent {
|
||||||
|
data, derr := w.store.GetTorrentData(ctx, d.ID)
|
||||||
|
if derr != nil {
|
||||||
|
return "", qbt.AddRequest{}, fmt.Errorf("torrent bytes: %w", derr)
|
||||||
|
}
|
||||||
|
if info, perr := torrent.Parse(data); perr == nil {
|
||||||
|
hint = info.DisplayName
|
||||||
|
}
|
||||||
|
req.Torrents = [][]byte{data}
|
||||||
|
return hint, req, nil
|
||||||
|
}
|
||||||
|
// magnet/url: SourceRef — добавляемая ссылка, и источник hint для namer.
|
||||||
|
if info, perr := magnet.Parse(d.SourceRef); perr == nil {
|
||||||
|
hint = info.DisplayName
|
||||||
|
}
|
||||||
|
req.URLs = []string{d.SourceRef}
|
||||||
|
return hint, req, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Poll сверяет активные задачи с состоянием qBittorrent и двигает их.
|
// Poll сверяет активные задачи с состоянием qBittorrent и двигает их.
|
||||||
// Листаем все торренты (а не только свою категорию), чтобы reconcile нашёл и
|
// Листаем все торренты (а не только свою категорию), чтобы reconcile нашёл и
|
||||||
// усыновлённые по тегу раздачи, а discovery — увидел новые.
|
// усыновлённые по тегу раздачи, а discovery — увидел новые.
|
||||||
@@ -674,12 +705,21 @@ func (w *Worker) Retry(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
return fmt.Errorf("retry: %w", err)
|
return fmt.Errorf("retry: %w", err)
|
||||||
}
|
}
|
||||||
if !alive && d.SourceType == store.SourceMagnet {
|
if !alive {
|
||||||
if err := w.qbt.Add(ctx, qbt.AddRequest{
|
// Добавляем заново по типу источника (magnet — ссылкой, torrent —
|
||||||
URLs: []string{d.SourceRef},
|
// сохранёнными байтами файлом). Rename при retry не выводим (namer здесь
|
||||||
Category: w.cfg.Category,
|
// не зовём — имя уже могло быть выведено при первом добавлении).
|
||||||
SavePath: w.cfg.SavePath,
|
_, addReq, prepErr := w.sourceAddParts(ctx, *d)
|
||||||
}); err != nil {
|
if prepErr != nil {
|
||||||
|
// Байты torrent недоступны — откатываем активацию, задача не должна
|
||||||
|
// «качаться» без раздачи в qBittorrent.
|
||||||
|
if rbErr := w.store.SetDownloadState(ctx, id, d.State, d.ErrorCode.String, d.ErrorMsg.String); rbErr != nil {
|
||||||
|
w.log.Error("retry rollback failed",
|
||||||
|
"capability", capReview, "download_id", id, "error", rbErr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("retry: prepare add: %w", prepErr)
|
||||||
|
}
|
||||||
|
if err := w.qbt.Add(ctx, addReq); err != nil {
|
||||||
// Активация уже прошла — откатываем задачу в прежнее состояние,
|
// Активация уже прошла — откатываем задачу в прежнее состояние,
|
||||||
// чтобы не оставить «качающуюся» задачу без раздачи в qBittorrent.
|
// чтобы не оставить «качающуюся» задачу без раздачи в qBittorrent.
|
||||||
if rbErr := w.store.SetDownloadState(ctx, id, d.State, d.ErrorCode.String, d.ErrorMsg.String); rbErr != nil {
|
if rbErr := w.store.SetDownloadState(ctx, id, d.State, d.ErrorCode.String, d.ErrorMsg.String); rbErr != nil {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const (
|
|||||||
type fakeStore struct {
|
type fakeStore struct {
|
||||||
downloads map[string]*store.Download
|
downloads map[string]*store.Download
|
||||||
transitions []transition
|
transitions []transition
|
||||||
|
torrents map[string][]byte // download_id → байты .torrent
|
||||||
}
|
}
|
||||||
|
|
||||||
type transition struct {
|
type transition struct {
|
||||||
@@ -99,7 +100,7 @@ func (f *fakeStore) FindActiveByInfohash(_ context.Context, hashes ...string) (*
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error) {
|
func (f *fakeStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error) {
|
||||||
if existing, _ := f.FindActiveByInfohash(ctx, hashes...); existing != nil {
|
if existing, _ := f.FindActiveByInfohash(ctx, hashes...); existing != nil {
|
||||||
return existing, nil
|
return existing, nil
|
||||||
}
|
}
|
||||||
@@ -111,11 +112,24 @@ func (f *fakeStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Downl
|
|||||||
cp.Infohashes = append(cp.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
|
cp.Infohashes = append(cp.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
|
||||||
}
|
}
|
||||||
f.downloads[id] = &cp
|
f.downloads[id] = &cp
|
||||||
|
if len(torrentBlob) > 0 {
|
||||||
|
if f.torrents == nil {
|
||||||
|
f.torrents = map[string][]byte{}
|
||||||
|
}
|
||||||
|
f.torrents[id] = torrentBlob
|
||||||
|
}
|
||||||
d.ID = id
|
d.ID = id
|
||||||
d.Infohashes = cp.Infohashes
|
d.Infohashes = cp.Infohashes
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetTorrentData(_ context.Context, downloadID string) ([]byte, error) {
|
||||||
|
if data, ok := f.torrents[downloadID]; ok {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
return nil, store.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeStore) ActivateIfNoOtherActive(ctx context.Context, id string, st store.State, code, msg string) error {
|
func (f *fakeStore) ActivateIfNoOtherActive(ctx context.Context, id string, st store.State, code, msg string) error {
|
||||||
d, ok := f.downloads[id]
|
d, ok := f.downloads[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-08
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Приём — быстрый (`fast-catch-ingest`): `Ingest` синхронно парсит источник,
|
||||||
|
синтезирует контекст, атомарно дедуплицирует и пишет `download` в `catched`,
|
||||||
|
сразу отвечая транспорту. Вывод имени (LLM) и `qbt.Add` делает воркер на шаге
|
||||||
|
`processCatched` (`internal/worker/worker.go:355-369`), беря источник из
|
||||||
|
`d.SourceRef` и добавляя его через `qbt.AddRequest.URLs`.
|
||||||
|
|
||||||
|
Готовое к переиспользованию:
|
||||||
|
|
||||||
|
- `qbt.AddRequest.Torrents [][]byte` + `qbt.Client.Add` уже шлют `.torrent`
|
||||||
|
байтами (multipart `torrents=fileN.torrent`, `internal/qbt/qbt.go:203`). Клиент
|
||||||
|
не трогаем.
|
||||||
|
- `store.SourceTorrent` уже объявлен (`internal/store/download.go:137`), но не
|
||||||
|
используется.
|
||||||
|
- `CreateDownloadIfNoActive(ctx, d, hashes)` (`:249`) атомарно держит инвариант
|
||||||
|
«≤1 активная загрузка на infohash» и пишет `download` + `download_infohash`.
|
||||||
|
Требует инфохэши **до** вызова.
|
||||||
|
|
||||||
|
Ключевое отличие magnet от файла: у magnet `SourceRef` (строка ссылки) — и
|
||||||
|
идентификатор, и то, что добавляется в qBittorrent. У `.torrent` «источник» —
|
||||||
|
**байты**, и без них qBittorrent на закрытых трекерах/без DHT метаданные не
|
||||||
|
получит. Значит, байты нельзя схлопнуть в magnet — их надо сохранить и
|
||||||
|
скормить воркеру как есть.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Принять `.torrent` как загруженные байты (Telegram-документ, файл-пикер
|
||||||
|
веба), извлечь из файла максимум контекста без сети, добавить в qBittorrent
|
||||||
|
байтами (метаданные не теряем).
|
||||||
|
- Переиспользовать существующий быстрый путь и инвариант дедупа: инфохэш из
|
||||||
|
файла → тот же `CreateDownloadIfNoActive`.
|
||||||
|
- Ветвление по типу источника локализовать (ingest — парс; worker — add), не
|
||||||
|
расползаясь по коду.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Торрент по http(s)-ссылке (фетч, SSRF-гард) — отдельная задача.
|
||||||
|
- Изменение `naming.DeriveName` / recognition — только богатеет вход
|
||||||
|
(`download.Context`).
|
||||||
|
- Изменение схемы `download`, magnet-пути, клиента qBittorrent.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Р1. Парсер `internal/torrent` поверх `anacrolix/torrent/metainfo`
|
||||||
|
|
||||||
|
Новый пакет по образцу `internal/magnet`: `Parse(data []byte) (Info, error)`.
|
||||||
|
API (проверено сборкой): `metainfo.Load(r)` хранит сырые `InfoBytes`;
|
||||||
|
`mi.HashInfoBytes()` даёт **v1**-инфохэш (SHA1 **исходных** байтов info-словаря,
|
||||||
|
без переэнкода — критично: любое переупорядочивание ключей/переформат целых
|
||||||
|
поменяло бы хеш и сломало сопоставление с qBittorrent и дедуп);
|
||||||
|
`mi.UnmarshalInfo()` → `info.Name`, `info.Files`, `info.TotalLength()`;
|
||||||
|
трекеры — из `mi.Announce` + плоский `mi.AnnounceList`; `mi.Comment`.
|
||||||
|
|
||||||
|
**Инфохэши — как их сообщает qBittorrent, не «всегда v1».** Формулировка
|
||||||
|
«всегда считаем v1» неверна для v2-only торрентов (BEP52): у них v1-хеша нет, а
|
||||||
|
qBittorrent отдаёт раздачу под 64-hex `infohash_v2`. Поэтому парсер извлекает те
|
||||||
|
хеши, которые файл реально несёт: для v1/гибрида — v1 (`HashInfoBytes`), для
|
||||||
|
v2/гибрида — дополнительно v2 (SHA256 info при `meta version 2`). Для чистого
|
||||||
|
v2-only мы храним именно v2, иначе `torrentFor` (`worker.go:512`) не совпадёт и
|
||||||
|
задача повиснет как «нет в qBittorrent». Точное имя v2-API anacrolix
|
||||||
|
(`HashInfoBytes` — только v1; v2 — через v2-тип библиотеки) **уточняется при
|
||||||
|
apply** и проверяется тестом на v2-only/гибридном файле; современные раздачи в
|
||||||
|
основном v1, но абсолют «SHALL v1» из спеки убран.
|
||||||
|
|
||||||
|
`Info` (аналогично `magnet.Info`):
|
||||||
|
|
||||||
|
```
|
||||||
|
type Info struct {
|
||||||
|
Infohash string // v1 приоритетно (нижний hex)
|
||||||
|
Infohashes []string // v1 (+ v2, если гибрид) — как в magnet.Info
|
||||||
|
DisplayName string // info.name
|
||||||
|
Files []File // {Path, Length}; для одиночного файла — один элемент
|
||||||
|
TotalLength int64 // сумма длин
|
||||||
|
Trackers []string // announce + announce-list (flatten)
|
||||||
|
Comment string // comment (опц.)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`func (i Info) Context() string` синтезирует строки-факты (через `\n`, тем же
|
||||||
|
стилем, что `magnet.Context()`): имя раздачи; `Размер:` человекочитаемо из
|
||||||
|
`TotalLength`; `Файлы:` — число и характерные расширения/крупнейший файл (даёт
|
||||||
|
recognition сигнал «фильм vs сериал» по числу видеофайлов); `Трекер:` — домен
|
||||||
|
происхождения; комментарий, если содержателен. Без сети.
|
||||||
|
|
||||||
|
_Совместное с magnet:_ хелперы `humanSize`/`originDomains` в `internal/magnet`
|
||||||
|
неэкспортируемы. Дублируем их тривиальные версии в `internal/torrent` (пакеты
|
||||||
|
независимы, дрейф маловероятен для «байты→GiB» и «url→домен»), либо — если при
|
||||||
|
apply дублирование ощущается лишним — выносим в маленький общий
|
||||||
|
`internal/mediactx`. Решение — при apply; на спеку не влияет.
|
||||||
|
|
||||||
|
_Альтернатива (отклонена пользователем):_ свой bencode-декодер без зависимости.
|
||||||
|
Выбрана проверенная библиотека ради корректного извлечения сырых info-байтов.
|
||||||
|
|
||||||
|
### Р2. Персистентность байтов — таблица-спутник `download_torrent`
|
||||||
|
|
||||||
|
Байты нужны воркеру на шаге добавления, а между приёмом и добавлением загрузка
|
||||||
|
живёт в `catched`. Храним байты в БД (атомарно с загрузкой, чистятся вместе с
|
||||||
|
ней, не требуют управления файлами на диске — в духе «минимум компонентов»):
|
||||||
|
|
||||||
|
```
|
||||||
|
CREATE TABLE download_torrent (
|
||||||
|
download_id TEXT PRIMARY KEY
|
||||||
|
REFERENCES download(id) ON DELETE CASCADE,
|
||||||
|
data BLOB NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Отдельная таблица, а не колонка на `download`: выборки списка/детали читают
|
||||||
|
`download` целиком (`SELECT *`), а блоб (десятки КБ, изредка единицы МБ) там ни
|
||||||
|
к чему — тянули бы его на каждый рендер списка.
|
||||||
|
|
||||||
|
**Запись байтов — внутри транзакции создания (см. Р5, изменение API).** Тх
|
||||||
|
`CreateDownloadIfNoActive` внутренняя и коммитится сама (`download.go:256-304`) —
|
||||||
|
снаружи к ней не подцепиться. Чтобы байты писались атомарно с заведением
|
||||||
|
загрузки (иначе — окно краха: `catched`-строка без байтов, воркер её никогда не
|
||||||
|
добавит), метод получает байты параметром и пишет `download_torrent` **на ветке
|
||||||
|
создания**; при дедупе (загрузка не создана) байты не пишутся.
|
||||||
|
|
||||||
|
**Жизненный цикл блоба — байты живут весь срок строки загрузки.** `ON DELETE
|
||||||
|
CASCADE` корректна (FK включены, `store.go:40`), но сегодня `DELETE FROM
|
||||||
|
download` в коде нет — загрузки не удаляются, уходят в терминальные состояния.
|
||||||
|
Значит CASCADE — лишь страховка на будущий delete-путь, а не активная уборка.
|
||||||
|
Удалять байты после промоушена `catched → downloading` мы **не** будем: они
|
||||||
|
нужны `Retry` (Р4) — повторное добавление упавшего/застрявшего торрента идёт тем
|
||||||
|
же файлом. Плата — байты копятся (ограничены размерным лимитом × число
|
||||||
|
torrent-загрузок); это осознанный трейд-офф ради ретраев, не баг.
|
||||||
|
|
||||||
|
_Альтернатива:_ файл на data-томе, `SourceRef` = путь. Отклонено — добавляет
|
||||||
|
управление файлами (создание, уборка, рассинхрон с БД) ради экономии, которой
|
||||||
|
для торрент-файлов нет.
|
||||||
|
|
||||||
|
`.torrent` на диске бывают крупными (много файлов → много piece-хешей).
|
||||||
|
Ограничиваем размер входа на границе транспорта (константа, дефолт напр. 8 MiB
|
||||||
|
с запасом) — защита от разбухания БД и oversized-загрузок.
|
||||||
|
|
||||||
|
### Р3. `SourceRef` у torrent — человекочитаемая ссылка, не адрес добавления
|
||||||
|
|
||||||
|
Для magnet `SourceRef` двойного назначения (ref + то, что добавляют). Для
|
||||||
|
torrent «то, что добавляют» — байты (Р2), поэтому `SourceRef` несёт только
|
||||||
|
**референс для человека/логов**: имя раздачи (`info.name`) либо имя файла.
|
||||||
|
Никакой код не должен добавлять torrent-загрузку по `SourceRef` как URL — это
|
||||||
|
гарантирует диспетч воркера по `SourceType` (Р4). Инфохэш-референс у загрузки и
|
||||||
|
так есть в `download_infohash`.
|
||||||
|
|
||||||
|
_Почему не синтезировать magnet в `SourceRef`:_ строка вида
|
||||||
|
`magnet:?xt=urn:btih:…` выглядела бы «добавляемой», провоцируя код слать её в
|
||||||
|
`URLs` и терять метаданные на закрытых трекерах. Явный не-URL `SourceRef` +
|
||||||
|
диспетч по типу убирают ловушку.
|
||||||
|
|
||||||
|
### Р4. Воркер: диспетч по `SourceType` во **всех** add-путях (общий хелпер)
|
||||||
|
|
||||||
|
В воркере **два** места добавляют источник в qBittorrent, и оба сегодня
|
||||||
|
предполагают magnet-URL — диспетч нужен в обоих:
|
||||||
|
|
||||||
|
1. `processCatched` (`worker.go:359-365`) — добавление пойманной загрузки.
|
||||||
|
2. `Retry` (`worker.go:677-691`) — повторное добавление упавшей/застрявшей.
|
||||||
|
Важно: сейчас `Retry` гейтит add на `d.SourceType == store.SourceMagnet`, то
|
||||||
|
есть для torrent он **активирует загрузку в `downloading`, не добавив
|
||||||
|
раздачу** → задача повиснет как «нет в qBittorrent» (нашёл ревью, BLOCKER 1).
|
||||||
|
Особенно опасно, т.к. `errCodeQbitAdd`-фейл не воскрешается сверкой — `Retry`
|
||||||
|
единственный выход.
|
||||||
|
|
||||||
|
Выносим общий хелпер `addSource(ctx, d, rename) error`, ветвящийся по
|
||||||
|
`d.SourceType`, и зовём его из обоих мест:
|
||||||
|
|
||||||
|
- `magnet`/`url` — hint из `magnet.Parse(d.SourceRef)`,
|
||||||
|
`qbt.Add(AddRequest{URLs: []string{d.SourceRef}, …})`.
|
||||||
|
- `torrent` — байты из `store.GetTorrentData(id)`; hint из `info.DisplayName`
|
||||||
|
(парс тех же байтов `torrent.Parse`); `qbt.Add(AddRequest{Torrents:
|
||||||
|
[][]byte{data}, Category, SavePath, Rename})`.
|
||||||
|
|
||||||
|
Ре-валидация перехода под блокировкой, обработка сбоя `add` (в `processCatched`
|
||||||
|
остаётся `catched`; в `Retry` — откат активации, `worker.go:683-689`),
|
||||||
|
предохранитель `catch_timeout` — прежние, не дублируются. Медленные вызовы
|
||||||
|
(namer, `qbt.Add`) — по-прежнему вне блокировки сериализации. Байты для `Retry`
|
||||||
|
доступны, потому что мы их не удаляем после промоушена (Р2).
|
||||||
|
|
||||||
|
### Р5. `ingest.Request` — путь для байтов; изменение API store ради атомарности
|
||||||
|
|
||||||
|
`Request` расширяется полем для байтов (`TorrentData []byte`, опц.).
|
||||||
|
Приоритет в `Ingest`: если `TorrentData` непуст — `torrent.Parse(data)`,
|
||||||
|
`SourceType = SourceTorrent`, `SourceRef` = имя (Р3), контекст =
|
||||||
|
`mergeContext(req.Context, tinfo.Context())`. Иначе — текущий magnet-путь без
|
||||||
|
изменений. Инфохэши, дедуп, атомарность — общий код `CreateDownloadIfNoActive`.
|
||||||
|
|
||||||
|
**Изменение API `CreateDownloadIfNoActive` (нашёл ревью, BLOCKER 2).** Чтобы
|
||||||
|
байты писались в той же транзакции (Р2), метод получает их аргументом (напр.
|
||||||
|
`CreateDownloadIfNoActive(ctx, d, hashes, torrentBlob []byte)`) и на ветке
|
||||||
|
создания делает `INSERT INTO download_torrent`; на ветке дедупа — не пишет.
|
||||||
|
Это тянет правку интерфейсов `Store` в `internal/ingest` и `internal/worker` и
|
||||||
|
существующих вызовов (magnet-приём и `discover.go` передают `nil`). Плюс метод
|
||||||
|
чтения `GetTorrentData(ctx, downloadID) ([]byte, error)` в те же интерфейсы для
|
||||||
|
воркера (Р4).
|
||||||
|
|
||||||
|
Форма транспортного ввода:
|
||||||
|
|
||||||
|
- **HTTP/web** (`handleUIAdd`): `multipart/form-data`; при наличии файла
|
||||||
|
`torrent` читаем байты (через `http.MaxBytesReader`/лимит Р2) → `Request{
|
||||||
|
TorrentData, Context}`; иначе — `Request{Source, Context}` как сейчас. Handler
|
||||||
|
сейчас — простой PRG `303` (не htmx, `httpapi.go:397-411`); так и остаётся,
|
||||||
|
деградация без JS сохраняется.
|
||||||
|
- **Telegram** (`handleMessage`): распознавание `.torrent`-документа надо
|
||||||
|
вставить **до** ветки pending-hint и текстовой ветки (`bot.go:124-146`):
|
||||||
|
у документа `m.Text` пуст (текст — в `m.Caption`), иначе pending-hint
|
||||||
|
«съест» документ как пустую подсказку. При `m.Document` с mime
|
||||||
|
`application/x-bittorrent` или расширением `.torrent` — скачиваем байты через
|
||||||
|
Bot API (`GetFileDirectURL`, есть в v5.5.1, `bot.go:269`, + HTTP GET, лимит
|
||||||
|
Р2) → `Request{TorrentData, Context: m.Caption}`. Скачивание с серверов
|
||||||
|
Telegram (доверенный источник, не произвольный SSRF); латентность приёмлема
|
||||||
|
для бот-взаимодействия. Документ не-`.torrent` → прежний ответ-подсказка.
|
||||||
|
`AllowedUpdates` менять не нужно — документ приходит внутри `message`.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Блоб в SQLite раздувает БД] → Лимит размера на границе (Р2), отдельная
|
||||||
|
таблица (не тянется в выборках), CASCADE-уборка вместе с загрузкой. Торрент-
|
||||||
|
файлы малы; крупные (много файлов) отсекаются лимитом.
|
||||||
|
- [Зависимость `anacrolix/torrent` тяжёлая] → Импортируем только подпакет
|
||||||
|
`metainfo`; сетевой/DHT-код не тянется в бинарь (только в go.sum как граф
|
||||||
|
требований). Осознанный выбор ради корректного info-хеша.
|
||||||
|
- [`SourceRef`-не-URL ломает код, ждущий magnet] → Оба add-пути воркера
|
||||||
|
(`processCatched` и `Retry`) диспетчат по `SourceType` через общий хелпер
|
||||||
|
(Р4); `magnet.Parse(SourceRef)` зовётся только в magnet-ветке. Дисплейные
|
||||||
|
читатели `SourceRef` (`httpapi.go:653`, `download.go:99`, `review.go:104`,
|
||||||
|
`tgbot/render.go:217`) толерантны к не-URL-строке — регресса нет.
|
||||||
|
- [v2-only/гибрид torrent] → v1-хеш обязателен, v2 — best-effort; дозапись
|
||||||
|
недостающего v2 из qBittorrent уже покрыта требованием «Множество инфохэшей».
|
||||||
|
- [Telegram-документ большого размера/не-торрент] → Проверка mime/расширения +
|
||||||
|
лимит размера до скачивания; аккуратный ответ на не-`.torrent`.
|
||||||
|
- [Дубль контекста: имя из файла и подпись пользователя совпадают] →
|
||||||
|
`mergeContext` ставит текст пользователя первым; recognition толерантен к
|
||||||
|
повтору (известный трейд-офф, как в magnet-синтезе).
|
||||||
|
- [Утечка токена бота в логи при скачивании из Telegram] → Прямой URL файла
|
||||||
|
Telegram содержит токен (`…/file/bot<TOKEN>/…`); ошибки транспорта
|
||||||
|
(`*url.Error`) встраивают URL. `downloadFile` пропускает такие ошибки через
|
||||||
|
`stripURL` (оставляет только первопричину без URL) — токен в логи не попадает
|
||||||
|
(нашёл code-review; инвариант «секреты не в логи», logging.md).
|
||||||
|
- [Паника парсера на кривом/вырожденном `.torrent`] → Байты недоверенные;
|
||||||
|
библиотека (`UpvertedFiles`/`FileTree`) может паниковать (деление на ноль при
|
||||||
|
`piece length` 0). Извлечение дерева файлов обёрнуто `recover` — это лишь
|
||||||
|
контекст-сигнал; инфохэш и имя уже извлечены, приём не падает.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Аддитивно и обратносовместимо: новая таблица `download_torrent` (goose-миграция,
|
||||||
|
SQL DDL) + обновление ER-схемы `docs/specs/database.md`; таблица `download` не
|
||||||
|
меняется. Существующие magnet-загрузки не затронуты (у них строки в
|
||||||
|
`download_torrent` нет; воркер их не читает — ветка magnet). Откат — ревертом
|
||||||
|
кода; осиротевшая таблица безвредна (или down-миграция `DROP TABLE`).
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Точный состав строки `Файлы:` (число + расширения vs крупнейший файл) —
|
||||||
|
уточняем при apply, на спеку не влияет (спека фиксирует «сигнал по файлам»,
|
||||||
|
не формулировку).
|
||||||
|
- Дефолт лимита размера `.torrent` (8 MiB?) и нужен ли он в конфиге — решаем
|
||||||
|
при apply; на нормативную часть не влияет.
|
||||||
|
- Дублировать ли `humanSize`/`originDomains` в `internal/torrent` или вынести в
|
||||||
|
общий хелпер — мелочь реализации (Р1).
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Приём сейчас умеет только magnet-ссылку (`ingest.Request.Source` — строка;
|
||||||
|
`magnet.Parse` — единственный парсер; при неудаче — ошибка «.torrent/url —
|
||||||
|
следующий заход»). Но пользователи часто получают с трекера именно `.torrent`-
|
||||||
|
файл, а не magnet, и у файла есть два преимущества, которых нет у голого
|
||||||
|
magnet:
|
||||||
|
|
||||||
|
- **Работает там, где magnet не резолвится.** Раздачи закрытых трекеров и
|
||||||
|
торренты без DHT по magnet-хешу метаданные не докачают (`metaDL` навсегда).
|
||||||
|
`.torrent` несёт полный info-словарь — qBittorrent стартует сразу, без
|
||||||
|
докачки метаданных.
|
||||||
|
- **Максимум контекста без сети.** В файле уже лежат реальное имя раздачи,
|
||||||
|
дерево файлов с размерами, суммарный размер, трекеры, комментарий — гораздо
|
||||||
|
богаче полей magnet. Это прямой сигнал для recognition ещё до старта
|
||||||
|
скачивания.
|
||||||
|
|
||||||
|
Инфраструктура почти готова: `qbt.AddRequest.Torrents [][]byte` и
|
||||||
|
`qbt.Client.Add` уже умеют слать `.torrent` байтами (multipart-upload),
|
||||||
|
константа `store.SourceTorrent` уже объявлена. Не хватает трёх вещей: парсера
|
||||||
|
байтов в инфохэш+контекст, персистентности байтов между быстрым приёмом
|
||||||
|
(`catched`) и добавлением воркером, и путей приёма файла в транспортах
|
||||||
|
(Telegram-документ, файл-пикер в вебе).
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Новый парсер `internal/torrent`** поверх `github.com/anacrolix/torrent/
|
||||||
|
metainfo`: `Parse(data []byte) (Info, error)` вытаскивает инфохэш(и) (v1
|
||||||
|
обязательно, v2 при наличии), имя раздачи, список файлов с размерами,
|
||||||
|
суммарный размер, трекеры, комментарий. Инфохэш v1 — SHA1 **исходных** байтов
|
||||||
|
info-словаря (без переэнкода). `func (Info) Context() string` синтезирует
|
||||||
|
человекочитаемый контекст распознавания из этих полей — в том же стиле, что
|
||||||
|
`magnet.Info.Context()`.
|
||||||
|
- **Приём (`ingest`) принимает байты `.torrent`.** `ingest.Request` получает
|
||||||
|
путь для байтов торрент-файла; при их наличии источник парсится
|
||||||
|
`torrent.Parse` (иначе — как сейчас, `magnet.Parse`). Загрузка заводится с
|
||||||
|
`SourceType = SourceTorrent`, инфохэши и дедуп — тем же атомарным путём
|
||||||
|
(`CreateDownloadIfNoActive`), контекст — синтез из полей файла, слитый с
|
||||||
|
текстом транспорта.
|
||||||
|
- **Персистентность байтов.** Байты `.torrent` нужны воркеру на шаге добавления
|
||||||
|
(быстрый приём лишь сохраняет `catched`). Заводим таблицу-спутник
|
||||||
|
`download_torrent(download_id, data)` — байты живут и удаляются вместе с
|
||||||
|
загрузкой, не раздувая выборки `download`.
|
||||||
|
- **Воркер добавляет по типу источника.** `processCatched` ветвится по
|
||||||
|
`SourceType`: magnet/url — как сейчас (`URLs`, hint из `magnet.Parse`);
|
||||||
|
torrent — грузит байты из `download_torrent`, hint из имени раздачи,
|
||||||
|
добавляет через `qbt.AddRequest.Torrents`.
|
||||||
|
- **Веб-UI: файл-пикер.** Рядом со строкой ввода источника — `<input
|
||||||
|
type="file" accept=".torrent">` и `enctype="multipart/form-data"` на форме.
|
||||||
|
Выбран файл — приём по байтам; иначе — по тексту. Деградация без JS
|
||||||
|
сохраняется (обычный multipart-POST).
|
||||||
|
- **Telegram: приём документа.** `handleMessage` распознаёт `.torrent`-
|
||||||
|
документ (mime `application/x-bittorrent` / расширение), скачивает его байты
|
||||||
|
через Bot API и подаёт в приём; подпись сообщения идёт контекстом.
|
||||||
|
|
||||||
|
Вне объёма (сознательно):
|
||||||
|
|
||||||
|
- **Торрент по http(s)-ссылке** (URL на `.torrent`) — отдельная задача: фетч
|
||||||
|
тянет сеть в fast-path приёма, требует SSRF-гарда, таймаутов и обработки
|
||||||
|
ошибок загрузки.
|
||||||
|
- Изменения механизма вывода отображаемого имени и распознавания — не
|
||||||
|
трогаем; они лишь получают более богатый контекст.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
_Нет._ Изменение укладывается в существующие capability.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `ingest`: добавляется приём источника из байтов `.torrent` (парс метаданных,
|
||||||
|
извлечение инфохэшей, синтез контекста из полей файла, `SourceType =
|
||||||
|
torrent`, персистентность байтов до добавления). Существующий magnet-путь и
|
||||||
|
инварианты дедупа/инфохэшей — без изменений.
|
||||||
|
- `download-tracking`: шаг «добавление пойманной загрузки в qBittorrent»
|
||||||
|
ветвится по типу источника — для torrent источник добавляется байтами файла,
|
||||||
|
hint отображаемого имени берётся из метаданных торрента.
|
||||||
|
- `web-ui`: форма добавления получает файловый ввод `.torrent`
|
||||||
|
(`multipart/form-data`), с деградацией без JS.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Код: новый `internal/torrent` (парсер + `Context()`); `internal/ingest`
|
||||||
|
(ветка байтов, `SourceTorrent`, запись байтов); `internal/store` (таблица
|
||||||
|
`download_torrent`, чтение/запись байтов, миграция); `internal/worker`
|
||||||
|
(`processCatched` — диспетч по `SourceType`); `internal/httpapi` (multipart в
|
||||||
|
`handleUIAdd`, размерный лимит); `internal/tgbot` (обработка `m.Document`,
|
||||||
|
скачивание файла).
|
||||||
|
- Зависимости: `+ github.com/anacrolix/torrent` (пакет `metainfo`) в go.mod.
|
||||||
|
- Данные: новая таблица `download_torrent` (миграция goose) + обновление
|
||||||
|
ER-схемы `docs/specs/database.md`. Таблица `download` не меняется.
|
||||||
|
- Конфиг: размерный лимит `.torrent` — константа с разумным дефолтом (вынос в
|
||||||
|
конфиг — при необходимости, отдельно).
|
||||||
|
- Совместимость: аддитивно; существующие magnet-загрузки не затронуты.
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Добавление пойманной загрузки в qBittorrent
|
||||||
|
|
||||||
|
Worker SHALL периодически (в поллинг-цикле, под единой блокировкой переходов)
|
||||||
|
подхватывать загрузки в состоянии `catched` и для каждой: вывести отображаемое
|
||||||
|
имя из контекста (см. `ingest` «Отображаемое имя торрента из контекста»),
|
||||||
|
добавить источник в qBittorrent (категория `qbittorrent.category`, savepath,
|
||||||
|
`rename`) и перевести загрузку `catched → downloading`. Отдельного состояния
|
||||||
|
между `catched` и `downloading` быть SHALL NOT — успешный `add` сразу переводит
|
||||||
|
в `downloading` (которое и означает «в qBit, возможно `metaDL`»).
|
||||||
|
|
||||||
|
Добавление в qBittorrent worker SHALL выполнять **по типу источника**
|
||||||
|
(`source_type`):
|
||||||
|
|
||||||
|
- Для `magnet`/`url` — передавать `source_ref` как ссылку (`urls` API
|
||||||
|
`/torrents/add`); подсказку отображаемого имени брать из полей самой ссылки.
|
||||||
|
- Для `torrent` — загружать сохранённые байты `.torrent` (привязанные к
|
||||||
|
загрузке при приёме) и передавать их **файлом** (`torrents` API
|
||||||
|
`/torrents/add`), НЕ как ссылку; подсказку отображаемого имени брать из
|
||||||
|
метаданных торрента (имя раздачи). Добавление байтами SHALL сохранять полные
|
||||||
|
метаданные (qBittorrent стартует без докачки), поэтому воскрешать раздачу по
|
||||||
|
magnet-хешу вместо файла система SHALL NOT.
|
||||||
|
|
||||||
|
Неуспешный `add` (qBittorrent недоступен и т.п.) SHALL оставлять загрузку в
|
||||||
|
`catched` для повторной попытки на следующем тике; переход в терминальное
|
||||||
|
состояние по единичному сбою происходить SHALL NOT (ретраи — естественными
|
||||||
|
тиками поллинга).
|
||||||
|
|
||||||
|
Медленные вызовы (вывод имени через LLM, `qbt.Add`) SHALL выполняться **вне**
|
||||||
|
блокировки сериализации переходов, чтобы не задерживать команды транспортов и
|
||||||
|
поллинг. Под блокировкой сериализуется только **запись перехода** `catched →
|
||||||
|
downloading` (см. «Переходы состояний сериализуются воркером»), с
|
||||||
|
ре-валидацией, что загрузка всё ещё в `catched` (иначе переход отклоняется —
|
||||||
|
например, при параллельной отмене).
|
||||||
|
|
||||||
|
#### Scenario: Пойманная magnet-загрузка добавляется в qBittorrent
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в состоянии `catched` с `source_type = magnet`
|
||||||
|
- **WHEN** worker обрабатывает тик
|
||||||
|
- **THEN** выводится отображаемое имя, ссылка добавляется в qBittorrent с
|
||||||
|
нашей категорией и `rename`
|
||||||
|
- **AND** загрузка переходит в `downloading`
|
||||||
|
|
||||||
|
#### Scenario: Пойманная .torrent-загрузка добавляется файлом
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в состоянии `catched` с `source_type = torrent` и
|
||||||
|
сохранёнными байтами файла
|
||||||
|
- **WHEN** worker обрабатывает тик
|
||||||
|
- **THEN** сохранённые байты добавляются в qBittorrent файлом (`torrents`), с
|
||||||
|
нашей категорией и `rename`, без обращения к magnet-хешу
|
||||||
|
- **AND** загрузка переходит в `downloading`
|
||||||
|
|
||||||
|
#### Scenario: Временный сбой добавления — повтор
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `catched`, qBittorrent временно недоступен
|
||||||
|
- **WHEN** worker пытается добавить источник и `add` не удался
|
||||||
|
- **THEN** загрузка остаётся в `catched`
|
||||||
|
- **AND** на следующем тике попытка добавления повторяется
|
||||||
|
|
||||||
|
#### Scenario: Отмена во время добавления
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `catched`, worker выводит имя и добавляет её вне
|
||||||
|
блокировки
|
||||||
|
- **WHEN** параллельно приходит команда отмены (`catched → cancelled`), а затем
|
||||||
|
worker берёт блокировку для записи перехода
|
||||||
|
- **THEN** ре-валидация видит, что загрузка уже не в `catched`, и переход в
|
||||||
|
`downloading` не применяется
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Приём источника из .torrent-файла
|
||||||
|
|
||||||
|
Приём SHALL принимать источник в виде **байтов `.torrent`-файла** (наряду с
|
||||||
|
magnet-ссылкой) — тем же быстрым use-case, общим для транспортов. Получив
|
||||||
|
непустые байты торрента, система SHALL разобрать их локально (без сети),
|
||||||
|
извлечь инфохэш(и) и завести загрузку с `source_type = torrent`, после чего
|
||||||
|
сразу вернуть ответ транспорту (синхронный путь к qBittorrent не обращается —
|
||||||
|
добавление делает воркер, см. `download-tracking`).
|
||||||
|
|
||||||
|
Инфохэши система SHALL извлекать такими, какими их сообщает qBittorrent, чтобы
|
||||||
|
сопоставление раздач и дедупликация работали: v1-хеш (для v1/гибридного файла)
|
||||||
|
SHALL вычисляться как SHA1 **исходных** байтов info-словаря (без переэнкода);
|
||||||
|
v2-хеш (для v2/гибридного файла, BEP52) SHALL извлекаться как 64-hex `infohash_v2`.
|
||||||
|
Для чистого v2-only файла система SHALL записывать v2-хеш (v1 у него нет).
|
||||||
|
Извлечение всех известных хешей и дозапись недостающих подчиняются требованию
|
||||||
|
«Множество инфохэшей загрузки».
|
||||||
|
|
||||||
|
Дедупликацию по активной задаче, атомарное заведение (`download` в состоянии
|
||||||
|
`catched` + записи `download_infohash`) и инвариант «не более одной активной
|
||||||
|
загрузки на infohash» torrent-приём SHALL проходить тем же атомарным путём, что
|
||||||
|
и magnet (см. «Приём источника и заведение загрузки», «Дедупликация приёма по
|
||||||
|
любому из хешей», «Атомарность возврата загрузки в активное состояние»).
|
||||||
|
|
||||||
|
Байты `.torrent` система SHALL сохранять персистентно, привязанными к загрузке,
|
||||||
|
чтобы воркер мог добавить источник в qBittorrent именно файлом (не по magnet):
|
||||||
|
раздачи закрытых трекеров и торренты без DHT по magnet-хешу метаданные не
|
||||||
|
получат. Сохранение байтов SHALL выполняться в той же write-транзакции, что и
|
||||||
|
заведение загрузки; при дедупликации (новая загрузка не создана) байты
|
||||||
|
сохраняться SHALL NOT. Размер принимаемого `.torrent` система SHALL ограничивать
|
||||||
|
на границе транспорта (защита от разбухания хранилища).
|
||||||
|
|
||||||
|
Из полей `.torrent` система SHALL синтезировать контекст распознавания (имя
|
||||||
|
раздачи, суммарный размер, сигнал по дереву файлов, домен трекера, комментарий)
|
||||||
|
и **дополнять** им контекст транспорта — тем же правилом слияния, что и синтез
|
||||||
|
из полей magnet (пользовательский текст первым; при пустом тексте — только
|
||||||
|
синтез). Обогащённый контекст система SHALL сохранять в `download.Context`.
|
||||||
|
Синтез SHALL выполняться без сетевых запросов.
|
||||||
|
|
||||||
|
`source_ref` у torrent-загрузки SHALL быть человекочитаемым референсом (имя
|
||||||
|
раздачи или файла), а НЕ адресом добавления: добавление в qBittorrent идёт
|
||||||
|
байтами, и трактовать `source_ref` как magnet/URL для добавления система SHALL
|
||||||
|
NOT.
|
||||||
|
|
||||||
|
#### Scenario: Быстрый приём .torrent-файла
|
||||||
|
|
||||||
|
- **GIVEN** валидные байты `.torrent`-файла и (опц.) текст контекста
|
||||||
|
- **WHEN** вызывается приём
|
||||||
|
- **THEN** из файла извлекаются инфохэши и создаётся `download` в состоянии
|
||||||
|
`catched` (`source_type = torrent`) с записями `download_infohash`
|
||||||
|
- **AND** байты файла сохраняются привязанными к загрузке
|
||||||
|
- **AND** ответ транспорту отдан без обращения к qBittorrent
|
||||||
|
|
||||||
|
#### Scenario: Инфохэш из исходных байтов info
|
||||||
|
|
||||||
|
- **WHEN** система разбирает v1/гибридный `.torrent`-файл
|
||||||
|
- **THEN** инфохэш v1 вычисляется как SHA1 исходных байтов info-словаря
|
||||||
|
- **AND** совпадает с хешем, по которому qBittorrent позже сопоставит раздачу
|
||||||
|
|
||||||
|
#### Scenario: v2-only файл записывается под v2-хешем
|
||||||
|
|
||||||
|
- **WHEN** система разбирает `.torrent` только с метаданными v2 (без v1)
|
||||||
|
- **THEN** у загрузки записывается v2-хеш (64-hex), совпадающий с `infohash_v2`
|
||||||
|
qBittorrent
|
||||||
|
- **AND** сопоставление раздачи работает по нему
|
||||||
|
|
||||||
|
#### Scenario: Дубль .torrent по активной задаче
|
||||||
|
|
||||||
|
- **GIVEN** уже есть активная (в т.ч. `catched`) загрузка с тем же infohash
|
||||||
|
- **WHEN** принимается `.torrent` с тем же инфохэшем
|
||||||
|
- **THEN** новая загрузка не создаётся, возвращается существующая
|
||||||
|
- **AND** байты торрента не сохраняются (дубль)
|
||||||
|
|
||||||
|
#### Scenario: Контекст из полей файла
|
||||||
|
|
||||||
|
- **WHEN** принят `.torrent` с именем раздачи, деревом файлов и трекерами
|
||||||
|
- **THEN** в `download.Context` добавляется синтез (имя, размер, сигнал по
|
||||||
|
файлам, домен трекера), дополняющий текст транспорта
|
||||||
|
- **AND** синтез выполнен без сетевых запросов
|
||||||
|
|
||||||
|
#### Scenario: Слишком большой .torrent отклоняется
|
||||||
|
|
||||||
|
- **WHEN** принимаемый `.torrent`-файл превышает ограничение размера
|
||||||
|
- **THEN** приём отклоняется с ошибкой, загрузка не создаётся
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Ручной повтор зависшей/упавшей загрузки из транспортов
|
||||||
|
|
||||||
|
Система SHALL предоставлять пользователю команду повторной попытки (retry)
|
||||||
|
для задач в `failed`/`stuck` из веб-UI и Telegram (не только через REST API).
|
||||||
|
Retry SHALL переводить задачу обратно в `downloading`, не вызывая её
|
||||||
|
немедленного повторного падения по таймауту: базис отсчёта таймаута SHALL
|
||||||
|
сбрасываться (отсчёт ведётся от факта в qBittorrent, а не от старого
|
||||||
|
`created_at`).
|
||||||
|
|
||||||
|
Если источник задачи уже жив в qBittorrent, retry SHALL перецепляться к
|
||||||
|
существующему торренту, а не добавлять источник повторно вслепую; повторный
|
||||||
|
`Add` выполняется, только когда раздачи в qBittorrent нет.
|
||||||
|
|
||||||
|
Повторный `Add` при retry система SHALL выполнять **по типу источника**
|
||||||
|
(`source_type`), как и добавление пойманной загрузки (см. `download-tracking`
|
||||||
|
«Добавление пойманной загрузки в qBittorrent»): magnet/url — ссылкой; torrent —
|
||||||
|
сохранёнными байтами `.torrent` файлом. Для torrent-источника retry БЕЗ живой
|
||||||
|
раздачи система SHALL добавлять раздачу байтами и SHALL NOT активировать задачу
|
||||||
|
в `downloading`, не добавив её (иначе задача повиснет как «нет в qBittorrent»).
|
||||||
|
|
||||||
|
#### Scenario: Retry упавшей magnet-загрузки из веб-UI
|
||||||
|
|
||||||
|
- **GIVEN** задача в `failed`, её торрент жив в qBittorrent
|
||||||
|
- **WHEN** пользователь нажимает retry в веб-UI
|
||||||
|
- **THEN** задача возвращается в `downloading` без повторного `Add`
|
||||||
|
- **AND** не падает снова на ближайшем тике сверки по таймауту
|
||||||
|
|
||||||
|
#### Scenario: Retry доступен в Telegram
|
||||||
|
|
||||||
|
- **WHEN** для задачи в `failed`/`stuck` пользователь вызывает retry в
|
||||||
|
Telegram-боте
|
||||||
|
- **THEN** задача возвращается в `downloading`
|
||||||
|
|
||||||
|
#### Scenario: Retry без живого источника добавляет источник заново
|
||||||
|
|
||||||
|
- **GIVEN** задача в `failed`, раздачи в qBittorrent нет
|
||||||
|
- **WHEN** пользователь инициирует retry
|
||||||
|
- **THEN** источник добавляется в qBittorrent заново — magnet/url ссылкой,
|
||||||
|
torrent сохранёнными байтами файлом
|
||||||
|
- **AND** задача переходит в `downloading`
|
||||||
|
|
||||||
|
#### Scenario: Retry torrent-загрузки без живого источника
|
||||||
|
|
||||||
|
- **GIVEN** задача с `source_type = torrent` в `failed`, раздачи в qBittorrent
|
||||||
|
нет, байты `.torrent` сохранены
|
||||||
|
- **WHEN** пользователь инициирует retry
|
||||||
|
- **THEN** сохранённые байты добавляются в qBittorrent файлом
|
||||||
|
- **AND** задача переходит в `downloading` (не остаётся без раздачи)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Загрузка .torrent-файла на форме добавления
|
||||||
|
|
||||||
|
Форма добавления загрузки веб-UI SHALL позволять выбрать локальный
|
||||||
|
`.torrent`-файл рядом со строкой ввода источника (кнопка/поле выбора файла).
|
||||||
|
При отправке формы с выбранным файлом система SHALL принять его байты
|
||||||
|
(`multipart/form-data`) и провести приём по `.torrent` (см. `ingest` «Приём
|
||||||
|
источника из .torrent-файла»); при пустом файловом поле — приём по тексту
|
||||||
|
источника, как прежде.
|
||||||
|
|
||||||
|
Файловый ввод SHALL деградировать без JavaScript: обычная отправка
|
||||||
|
`multipart`-формы SHALL приводить к приёму файла и тем же результатом, что и
|
||||||
|
htmx-путь (список обновляется/происходит редирект — как у существующего
|
||||||
|
добавления). Размер принимаемого файла UI/обработчик SHALL ограничивать (см.
|
||||||
|
ограничение размера в `ingest`); превышение SHALL давать понятную ошибку без
|
||||||
|
создания загрузки.
|
||||||
|
|
||||||
|
#### Scenario: Добавление выбором .torrent-файла
|
||||||
|
|
||||||
|
- **GIVEN** пользователь открыл форму добавления и выбрал `.torrent`-файл
|
||||||
|
- **WHEN** форма отправлена
|
||||||
|
- **THEN** файл принимается байтами и заводится загрузка (`source_type =
|
||||||
|
torrent`)
|
||||||
|
- **AND** список загрузок отражает новую задачу (как при добавлении по magnet)
|
||||||
|
|
||||||
|
#### Scenario: Файл не выбран — приём по тексту
|
||||||
|
|
||||||
|
- **GIVEN** пользователь оставил файловое поле пустым и ввёл magnet/текст
|
||||||
|
- **WHEN** форма отправлена
|
||||||
|
- **THEN** выполняется приём по тексту источника, как прежде
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
## 1. Парсер `internal/torrent`
|
||||||
|
|
||||||
|
- [x] 1.1 Добавить зависимость `github.com/anacrolix/torrent` (пакет
|
||||||
|
`metainfo`); `task tidy`
|
||||||
|
- [x] 1.2 Пакет `internal/torrent`: `Parse(data []byte) (Info, error)` через
|
||||||
|
`metainfo.Load`/`UnmarshalInfo`; `Info{Infohash, Infohashes, DisplayName,
|
||||||
|
Files, TotalLength, Trackers, Comment}`. Инфохэш v1 — из `HashInfoBytes`
|
||||||
|
(SHA1 исходных байтов info, нижний hex); v2/гибрид — извлечь v2 (64-hex,
|
||||||
|
как `infohash_v2` qBit), для v2-only хранить именно v2. Уточнить точный v2-API
|
||||||
|
anacrolix при apply
|
||||||
|
- [x] 1.3 `func (Info) Context() string` — строки-факты (`\n`): имя раздачи,
|
||||||
|
`Размер:` из `TotalLength`, сигнал по файлам (число/расширения), `Трекер:`
|
||||||
|
домен из announce/announce-list, комментарий (если содержателен). Без сети
|
||||||
|
- [x] 1.4 Хелперы размера/домена: дублировать тривиальные из `internal/magnet`
|
||||||
|
или вынести общий (решение по месту)
|
||||||
|
- [x] 1.5 Тесты: одиночный файл; много файлов; гибрид v1+v2; трекеры из
|
||||||
|
announce-list; повреждённые/не-torrent байты → ошибка; `Context()` —
|
||||||
|
содержательный и пустой случай; отсутствие сетевых вызовов
|
||||||
|
|
||||||
|
## 2. Хранилище: байты торрента
|
||||||
|
|
||||||
|
- [x] 2.1 Goose-миграция: таблица `download_torrent(download_id TEXT PRIMARY KEY
|
||||||
|
REFERENCES download(id) ON DELETE CASCADE, data BLOB NOT NULL)`
|
||||||
|
- [x] 2.2 Обновить ER-схему `docs/specs/database.md` (новая таблица + связь)
|
||||||
|
- [x] 2.3 Изменить сигнатуру `CreateDownloadIfNoActive(ctx, d, hashes,
|
||||||
|
torrentBlob []byte)`: на ветке создания `INSERT INTO download_torrent` в той
|
||||||
|
же tx (при дедупе — не пишет). Обновить интерфейсы `Store` в `internal/ingest`
|
||||||
|
и `internal/worker` и вызовы magnet-приёма/`discover.go` (передают `nil`).
|
||||||
|
Добавить `GetTorrentData(ctx, downloadID) ([]byte, error)`
|
||||||
|
- [x] 2.4 Тесты store: запись/чтение байтов; при дедупе байты не пишутся;
|
||||||
|
CASCADE-уборка при (гипотетическом) удалении загрузки. Байты НЕ удаляются при
|
||||||
|
промоушене `catched → downloading` (нужны для retry)
|
||||||
|
|
||||||
|
## 3. Приём: ветка байтов в `ingest`
|
||||||
|
|
||||||
|
- [x] 3.1 Расширить `ingest.Request` полем `TorrentData []byte` (опц.)
|
||||||
|
- [x] 3.2 В `Ingest`: при непустом `TorrentData` — `torrent.Parse`,
|
||||||
|
`SourceType = SourceTorrent`, `SourceRef` = имя раздачи/файла (не URL),
|
||||||
|
контекст = `mergeContext(req.Context, tinfo.Context())`, запись байтов (2.3)
|
||||||
|
в транзакции создания; иначе — прежний magnet-путь без изменений
|
||||||
|
- [x] 3.3 Ограничение размера входа на границе (константа, дефолт напр. 8 MiB)
|
||||||
|
- [x] 3.4 Тесты ingest: приём `.torrent` → `catched`, `source_type=torrent`,
|
||||||
|
инфохэши, байты сохранены, контекст синтезирован; дедуп по инфохэшу (байты не
|
||||||
|
пишутся); превышение лимита → ошибка
|
||||||
|
|
||||||
|
## 4. Воркер: диспетч добавления по типу источника (оба add-пути)
|
||||||
|
|
||||||
|
- [x] 4.1 Общий хелпер `addSource(ctx, d, rename)`: ветвь по `d.SourceType` —
|
||||||
|
magnet/url (`URLs`, hint из `magnet.Parse`); torrent (`GetTorrentData`, hint
|
||||||
|
из `torrent.Parse(data).DisplayName`, `qbt.Add(AddRequest{Torrents:
|
||||||
|
[][]byte{data}, …})`)
|
||||||
|
- [x] 4.2 Применить хелпер в `processCatched` (worker.go:359-365) И в `Retry`
|
||||||
|
(worker.go:677-691): снять magnet-only гейт в Retry — torrent без живой
|
||||||
|
раздачи должен добавляться байтами, а не активироваться без `Add`. Сохранить
|
||||||
|
откат активации при сбое add в Retry
|
||||||
|
- [x] 4.3 Общими остаются: ре-валидация перехода, обработка сбоя `add`
|
||||||
|
(`processCatched` — остаётся `catched`; Retry — откат), namer/qbt вне
|
||||||
|
блокировки
|
||||||
|
- [x] 4.4 Тесты воркера: (а) `catched` torrent → `qbt.Add` с `Torrents` (не
|
||||||
|
`URLs`), переход в `downloading`, сбой add → остаётся `catched`; (б) `Retry`
|
||||||
|
torrent без живой раздачи → `Add` с `Torrents`, переход в `downloading`; сбой
|
||||||
|
add → откат в прежнее состояние
|
||||||
|
|
||||||
|
## 5. Транспорт: веб-форма (multipart)
|
||||||
|
|
||||||
|
- [x] 5.1 Шаблон формы: `enctype="multipart/form-data"`, `<input type="file"
|
||||||
|
name="torrent" accept=".torrent,application/x-bittorrent">` рядом со строкой
|
||||||
|
источника; строка источника не обязательна при выбранном файле
|
||||||
|
- [x] 5.2 `handleUIAdd`: `http.MaxBytesReader`/лимит, `ParseMultipartForm`; есть
|
||||||
|
файл `torrent` → `Ingest(Request{TorrentData, Context})`; иначе — прежний
|
||||||
|
путь. PRG-редирект/htmx как сейчас
|
||||||
|
- [x] 5.3 Деградация без JS: обычный multipart-POST приводит к тому же
|
||||||
|
результату
|
||||||
|
- [x] 5.4 Тест обработчика: multipart с `.torrent` → загрузка заведена; без
|
||||||
|
файла → прежний текстовый путь; превышение размера → ошибка
|
||||||
|
|
||||||
|
## 6. Транспорт: Telegram-документ
|
||||||
|
|
||||||
|
- [x] 6.1 В `handleMessage` обрабатывать `m.Document` **до** ветки pending-hint
|
||||||
|
и текстовой ветки (у документа `m.Text` пуст): mime `application/x-bittorrent`
|
||||||
|
или расширение `.torrent` → скачать байты (`GetFileDirectURL` + HTTP GET,
|
||||||
|
лимит), `Ingest(Request{TorrentData, Context: m.Caption})`
|
||||||
|
- [x] 6.2 Документ не-`.torrent` → прежний ответ-подсказка; сохранить
|
||||||
|
allowlist-проверку. `AllowedUpdates` не менять (документ — внутри `message`)
|
||||||
|
- [x] 6.3 Тест парса/маршрутизации документа (mime/расширение → ветка байтов);
|
||||||
|
скачивание — за интерфейсом/мок
|
||||||
|
|
||||||
|
## 7. Проверка
|
||||||
|
|
||||||
|
- [x] 7.1 `task test` и `task lint` зелёные
|
||||||
|
- [x] 7.2 `openspec validate torrent-file-ingest --strict` проходит
|
||||||
|
- [ ] 7.3 Ручная проверка: `.torrent` через веб-пикер и Telegram-документ →
|
||||||
|
`catched → downloading`, задача видна в qBittorrent с `rename`
|
||||||
@@ -102,6 +102,18 @@ Worker SHALL периодически (в поллинг-цикле, под ед
|
|||||||
между `catched` и `downloading` быть SHALL NOT — успешный `add` сразу переводит
|
между `catched` и `downloading` быть SHALL NOT — успешный `add` сразу переводит
|
||||||
в `downloading` (которое и означает «в qBit, возможно `metaDL`»).
|
в `downloading` (которое и означает «в qBit, возможно `metaDL`»).
|
||||||
|
|
||||||
|
Добавление в qBittorrent worker SHALL выполнять **по типу источника**
|
||||||
|
(`source_type`):
|
||||||
|
|
||||||
|
- Для `magnet`/`url` — передавать `source_ref` как ссылку (`urls` API
|
||||||
|
`/torrents/add`); подсказку отображаемого имени брать из полей самой ссылки.
|
||||||
|
- Для `torrent` — загружать сохранённые байты `.torrent` (привязанные к
|
||||||
|
загрузке при приёме) и передавать их **файлом** (`torrents` API
|
||||||
|
`/torrents/add`), НЕ как ссылку; подсказку отображаемого имени брать из
|
||||||
|
метаданных торрента (имя раздачи). Добавление байтами SHALL сохранять полные
|
||||||
|
метаданные (qBittorrent стартует без докачки), поэтому воскрешать раздачу по
|
||||||
|
magnet-хешу вместо файла система SHALL NOT.
|
||||||
|
|
||||||
Неуспешный `add` (qBittorrent недоступен и т.п.) SHALL оставлять загрузку в
|
Неуспешный `add` (qBittorrent недоступен и т.п.) SHALL оставлять загрузку в
|
||||||
`catched` для повторной попытки на следующем тике; переход в терминальное
|
`catched` для повторной попытки на следующем тике; переход в терминальное
|
||||||
состояние по единичному сбою происходить SHALL NOT (ретраи — естественными
|
состояние по единичному сбою происходить SHALL NOT (ретраи — естественными
|
||||||
@@ -114,14 +126,23 @@ downloading` (см. «Переходы состояний сериализуют
|
|||||||
ре-валидацией, что загрузка всё ещё в `catched` (иначе переход отклоняется —
|
ре-валидацией, что загрузка всё ещё в `catched` (иначе переход отклоняется —
|
||||||
например, при параллельной отмене).
|
например, при параллельной отмене).
|
||||||
|
|
||||||
#### Scenario: Пойманная загрузка добавляется в qBittorrent
|
#### Scenario: Пойманная magnet-загрузка добавляется в qBittorrent
|
||||||
|
|
||||||
- **GIVEN** загрузка в состоянии `catched`
|
- **GIVEN** загрузка в состоянии `catched` с `source_type = magnet`
|
||||||
- **WHEN** worker обрабатывает тик
|
- **WHEN** worker обрабатывает тик
|
||||||
- **THEN** выводится отображаемое имя, источник добавляется в qBittorrent с
|
- **THEN** выводится отображаемое имя, ссылка добавляется в qBittorrent с
|
||||||
нашей категорией и `rename`
|
нашей категорией и `rename`
|
||||||
- **AND** загрузка переходит в `downloading`
|
- **AND** загрузка переходит в `downloading`
|
||||||
|
|
||||||
|
#### Scenario: Пойманная .torrent-загрузка добавляется файлом
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в состоянии `catched` с `source_type = torrent` и
|
||||||
|
сохранёнными байтами файла
|
||||||
|
- **WHEN** worker обрабатывает тик
|
||||||
|
- **THEN** сохранённые байты добавляются в qBittorrent файлом (`torrents`), с
|
||||||
|
нашей категорией и `rename`, без обращения к magnet-хешу
|
||||||
|
- **AND** загрузка переходит в `downloading`
|
||||||
|
|
||||||
#### Scenario: Временный сбой добавления — повтор
|
#### Scenario: Временный сбой добавления — повтор
|
||||||
|
|
||||||
- **GIVEN** загрузка в `catched`, qBittorrent временно недоступен
|
- **GIVEN** загрузка в `catched`, qBittorrent временно недоступен
|
||||||
|
|||||||
@@ -314,3 +314,87 @@ recognition (LLM-промпт) и веб-UI (страница загрузки).
|
|||||||
- **THEN** `download.Context` остаётся пустым
|
- **THEN** `download.Context` остаётся пустым
|
||||||
- **AND** приём проходит штатно (пустой контекст допустим)
|
- **AND** приём проходит штатно (пустой контекст допустим)
|
||||||
|
|
||||||
|
|
||||||
|
### Requirement: Приём источника из .torrent-файла
|
||||||
|
|
||||||
|
Приём SHALL принимать источник в виде **байтов `.torrent`-файла** (наряду с
|
||||||
|
magnet-ссылкой) — тем же быстрым use-case, общим для транспортов. Получив
|
||||||
|
непустые байты торрента, система SHALL разобрать их локально (без сети),
|
||||||
|
извлечь инфохэш(и) и завести загрузку с `source_type = torrent`, после чего
|
||||||
|
сразу вернуть ответ транспорту (синхронный путь к qBittorrent не обращается —
|
||||||
|
добавление делает воркер, см. `download-tracking`).
|
||||||
|
|
||||||
|
Инфохэши система SHALL извлекать такими, какими их сообщает qBittorrent, чтобы
|
||||||
|
сопоставление раздач и дедупликация работали: v1-хеш (для v1/гибридного файла)
|
||||||
|
SHALL вычисляться как SHA1 **исходных** байтов info-словаря (без переэнкода);
|
||||||
|
v2-хеш (для v2/гибридного файла, BEP52) SHALL извлекаться как 64-hex `infohash_v2`.
|
||||||
|
Для чистого v2-only файла система SHALL записывать v2-хеш (v1 у него нет).
|
||||||
|
Извлечение всех известных хешей и дозапись недостающих подчиняются требованию
|
||||||
|
«Множество инфохэшей загрузки».
|
||||||
|
|
||||||
|
Дедупликацию по активной задаче, атомарное заведение (`download` в состоянии
|
||||||
|
`catched` + записи `download_infohash`) и инвариант «не более одной активной
|
||||||
|
загрузки на infohash» torrent-приём SHALL проходить тем же атомарным путём, что
|
||||||
|
и magnet (см. «Приём источника и заведение загрузки», «Дедупликация приёма по
|
||||||
|
любому из хешей», «Атомарность возврата загрузки в активное состояние»).
|
||||||
|
|
||||||
|
Байты `.torrent` система SHALL сохранять персистентно, привязанными к загрузке,
|
||||||
|
чтобы воркер мог добавить источник в qBittorrent именно файлом (не по magnet):
|
||||||
|
раздачи закрытых трекеров и торренты без DHT по magnet-хешу метаданные не
|
||||||
|
получат. Сохранение байтов SHALL выполняться в той же write-транзакции, что и
|
||||||
|
заведение загрузки; при дедупликации (новая загрузка не создана) байты
|
||||||
|
сохраняться SHALL NOT. Размер принимаемого `.torrent` система SHALL ограничивать
|
||||||
|
на границе транспорта (защита от разбухания хранилища).
|
||||||
|
|
||||||
|
Из полей `.torrent` система SHALL синтезировать контекст распознавания (имя
|
||||||
|
раздачи, суммарный размер, сигнал по дереву файлов, домен трекера, комментарий)
|
||||||
|
и **дополнять** им контекст транспорта — тем же правилом слияния, что и синтез
|
||||||
|
из полей magnet (пользовательский текст первым; при пустом тексте — только
|
||||||
|
синтез). Обогащённый контекст система SHALL сохранять в `download.Context`.
|
||||||
|
Синтез SHALL выполняться без сетевых запросов.
|
||||||
|
|
||||||
|
`source_ref` у torrent-загрузки SHALL быть человекочитаемым референсом (имя
|
||||||
|
раздачи или файла), а НЕ адресом добавления: добавление в qBittorrent идёт
|
||||||
|
байтами, и трактовать `source_ref` как magnet/URL для добавления система SHALL
|
||||||
|
NOT.
|
||||||
|
|
||||||
|
#### Scenario: Быстрый приём .torrent-файла
|
||||||
|
|
||||||
|
- **GIVEN** валидные байты `.torrent`-файла и (опц.) текст контекста
|
||||||
|
- **WHEN** вызывается приём
|
||||||
|
- **THEN** из файла извлекаются инфохэши и создаётся `download` в состоянии
|
||||||
|
`catched` (`source_type = torrent`) с записями `download_infohash`
|
||||||
|
- **AND** байты файла сохраняются привязанными к загрузке
|
||||||
|
- **AND** ответ транспорту отдан без обращения к qBittorrent
|
||||||
|
|
||||||
|
#### Scenario: Инфохэш из исходных байтов info
|
||||||
|
|
||||||
|
- **WHEN** система разбирает v1/гибридный `.torrent`-файл
|
||||||
|
- **THEN** инфохэш v1 вычисляется как SHA1 исходных байтов info-словаря
|
||||||
|
- **AND** совпадает с хешем, по которому qBittorrent позже сопоставит раздачу
|
||||||
|
|
||||||
|
#### Scenario: v2-only файл записывается под v2-хешем
|
||||||
|
|
||||||
|
- **WHEN** система разбирает `.torrent` только с метаданными v2 (без v1)
|
||||||
|
- **THEN** у загрузки записывается v2-хеш (64-hex), совпадающий с `infohash_v2`
|
||||||
|
qBittorrent
|
||||||
|
- **AND** сопоставление раздачи работает по нему
|
||||||
|
|
||||||
|
#### Scenario: Дубль .torrent по активной задаче
|
||||||
|
|
||||||
|
- **GIVEN** уже есть активная (в т.ч. `catched`) загрузка с тем же infohash
|
||||||
|
- **WHEN** принимается `.torrent` с тем же инфохэшем
|
||||||
|
- **THEN** новая загрузка не создаётся, возвращается существующая
|
||||||
|
- **AND** байты торрента не сохраняются (дубль)
|
||||||
|
|
||||||
|
#### Scenario: Контекст из полей файла
|
||||||
|
|
||||||
|
- **WHEN** принят `.torrent` с именем раздачи, деревом файлов и трекерами
|
||||||
|
- **THEN** в `download.Context` добавляется синтез (имя, размер, сигнал по
|
||||||
|
файлам, домен трекера), дополняющий текст транспорта
|
||||||
|
- **AND** синтез выполнен без сетевых запросов
|
||||||
|
|
||||||
|
#### Scenario: Слишком большой .torrent отклоняется
|
||||||
|
|
||||||
|
- **WHEN** принимаемый `.torrent`-файл превышает ограничение размера
|
||||||
|
- **THEN** приём отклоняется с ошибкой, загрузка не создаётся
|
||||||
|
|||||||
@@ -129,6 +129,13 @@ Retry SHALL переводить задачу обратно в `downloading`,
|
|||||||
существующему торренту, а не добавлять источник повторно вслепую; повторный
|
существующему торренту, а не добавлять источник повторно вслепую; повторный
|
||||||
`Add` выполняется, только когда раздачи в qBittorrent нет.
|
`Add` выполняется, только когда раздачи в qBittorrent нет.
|
||||||
|
|
||||||
|
Повторный `Add` при retry система SHALL выполнять **по типу источника**
|
||||||
|
(`source_type`), как и добавление пойманной загрузки (см. `download-tracking`
|
||||||
|
«Добавление пойманной загрузки в qBittorrent»): magnet/url — ссылкой; torrent —
|
||||||
|
сохранёнными байтами `.torrent` файлом. Для torrent-источника retry БЕЗ живой
|
||||||
|
раздачи система SHALL добавлять раздачу байтами и SHALL NOT активировать задачу
|
||||||
|
в `downloading`, не добавив её (иначе задача повиснет как «нет в qBittorrent»).
|
||||||
|
|
||||||
#### Scenario: Retry упавшей magnet-загрузки из веб-UI
|
#### Scenario: Retry упавшей magnet-загрузки из веб-UI
|
||||||
|
|
||||||
- **GIVEN** задача в `failed`, её торрент жив в qBittorrent
|
- **GIVEN** задача в `failed`, её торрент жив в qBittorrent
|
||||||
@@ -142,13 +149,22 @@ Retry SHALL переводить задачу обратно в `downloading`,
|
|||||||
Telegram-боте
|
Telegram-боте
|
||||||
- **THEN** задача возвращается в `downloading`
|
- **THEN** задача возвращается в `downloading`
|
||||||
|
|
||||||
#### Scenario: Retry без живого источника добавляет торрент заново
|
#### Scenario: Retry без живого источника добавляет источник заново
|
||||||
|
|
||||||
- **GIVEN** задача в `failed`, раздачи в qBittorrent нет
|
- **GIVEN** задача в `failed`, раздачи в qBittorrent нет
|
||||||
- **WHEN** пользователь инициирует retry
|
- **WHEN** пользователь инициирует retry
|
||||||
- **THEN** источник (magnet) добавляется в qBittorrent заново
|
- **THEN** источник добавляется в qBittorrent заново — magnet/url ссылкой,
|
||||||
|
torrent сохранёнными байтами файлом
|
||||||
- **AND** задача переходит в `downloading`
|
- **AND** задача переходит в `downloading`
|
||||||
|
|
||||||
|
#### Scenario: Retry torrent-загрузки без живого источника
|
||||||
|
|
||||||
|
- **GIVEN** задача с `source_type = torrent` в `failed`, раздачи в qBittorrent
|
||||||
|
нет, байты `.torrent` сохранены
|
||||||
|
- **WHEN** пользователь инициирует retry
|
||||||
|
- **THEN** сохранённые байты добавляются в qBittorrent файлом
|
||||||
|
- **AND** задача переходит в `downloading` (не остаётся без раздачи)
|
||||||
|
|
||||||
### Requirement: Принудительная проверка источника/цели перед действием
|
### Requirement: Принудительная проверка источника/цели перед действием
|
||||||
|
|
||||||
Команда workflow, требующая наличия источника или цели, SHALL синхронно
|
Команда workflow, требующая наличия источника или цели, SHALL синхронно
|
||||||
|
|||||||
@@ -473,3 +473,33 @@ htmx-поллингом (см. конвенцию веб-UI): по перехо
|
|||||||
(бейдж, имя, живой прогресс)
|
(бейдж, имя, живой прогресс)
|
||||||
- **AND** поллинг фазы `catched` завершается
|
- **AND** поллинг фазы `catched` завершается
|
||||||
|
|
||||||
|
|
||||||
|
### Requirement: Загрузка .torrent-файла на форме добавления
|
||||||
|
|
||||||
|
Форма добавления загрузки веб-UI SHALL позволять выбрать локальный
|
||||||
|
`.torrent`-файл рядом со строкой ввода источника (кнопка/поле выбора файла).
|
||||||
|
При отправке формы с выбранным файлом система SHALL принять его байты
|
||||||
|
(`multipart/form-data`) и провести приём по `.torrent` (см. `ingest` «Приём
|
||||||
|
источника из .torrent-файла»); при пустом файловом поле — приём по тексту
|
||||||
|
источника, как прежде.
|
||||||
|
|
||||||
|
Файловый ввод SHALL деградировать без JavaScript: обычная отправка
|
||||||
|
`multipart`-формы SHALL приводить к приёму файла и тем же результатом, что и
|
||||||
|
htmx-путь (список обновляется/происходит редирект — как у существующего
|
||||||
|
добавления). Размер принимаемого файла UI/обработчик SHALL ограничивать (см.
|
||||||
|
ограничение размера в `ingest`); превышение SHALL давать понятную ошибку без
|
||||||
|
создания загрузки.
|
||||||
|
|
||||||
|
#### Scenario: Добавление выбором .torrent-файла
|
||||||
|
|
||||||
|
- **GIVEN** пользователь открыл форму добавления и выбрал `.torrent`-файл
|
||||||
|
- **WHEN** форма отправлена
|
||||||
|
- **THEN** файл принимается байтами и заводится загрузка (`source_type =
|
||||||
|
torrent`)
|
||||||
|
- **AND** список загрузок отражает новую задачу (как при добавлении по magnet)
|
||||||
|
|
||||||
|
#### Scenario: Файл не выбран — приём по тексту
|
||||||
|
|
||||||
|
- **GIVEN** пользователь оставил файловое поле пустым и ввёл magnet/текст
|
||||||
|
- **WHEN** форма отправлена
|
||||||
|
- **THEN** выполняется приём по тексту источника, как прежде
|
||||||
|
|||||||
@@ -191,6 +191,8 @@ a:hover{text-decoration:underline}
|
|||||||
padding:var(--sp-4);box-shadow:var(--shadow-sm);margin-bottom:var(--sp-5)}
|
padding:var(--sp-4);box-shadow:var(--shadow-sm);margin-bottom:var(--sp-5)}
|
||||||
.add-row{display:flex;gap:var(--sp-2)}
|
.add-row{display:flex;gap:var(--sp-2)}
|
||||||
.add-row .input{flex:1}
|
.add-row .input{flex:1}
|
||||||
|
.btn-file{color:var(--text-dim);font-weight:500}
|
||||||
|
.btn-file.has-file{color:var(--accent);border-color:var(--accent)}
|
||||||
.add-hint{margin:9px 2px 0;font-size:var(--fs-xs);color:var(--text-faint)}
|
.add-hint{margin:9px 2px 0;font-size:var(--fs-xs);color:var(--text-faint)}
|
||||||
|
|
||||||
/* ---------- 6. Карточка загрузки ---------- */
|
/* ---------- 6. Карточка загрузки ---------- */
|
||||||
|
|||||||
@@ -19,13 +19,18 @@
|
|||||||
{{if .Error}}<div class="result-banner" style="background:var(--st-err-bg);border-color:color-mix(in srgb,var(--st-err) 35%,transparent)"><h2 style="color:var(--st-err)">{{.Error}}</h2></div>{{end}}
|
{{if .Error}}<div class="result-banner" style="background:var(--st-err-bg);border-color:color-mix(in srgb,var(--st-err) 35%,transparent)"><h2 style="color:var(--st-err)">{{.Error}}</h2></div>{{end}}
|
||||||
|
|
||||||
<!-- Единое окно добавления -->
|
<!-- Единое окно добавления -->
|
||||||
<form class="add-box" method="post" action="/ui/downloads">
|
<form class="add-box" method="post" action="/ui/downloads" enctype="multipart/form-data">
|
||||||
<div class="add-row">
|
<div class="add-row">
|
||||||
<input class="input" type="text" name="source" autocomplete="off" required
|
<input class="input" type="text" name="source" autocomplete="off"
|
||||||
placeholder="magnet:?xt=urn:btih:… · ссылка · .torrent · или вставь текст бота">
|
placeholder="magnet:?xt=urn:btih:… · ссылка · или вставь текст бота">
|
||||||
|
<label class="btn btn-file" title="Выбрать .torrent-файл">
|
||||||
|
<span data-file-label>.torrent</span>
|
||||||
|
<input type="file" name="torrent" accept=".torrent,application/x-bittorrent" hidden
|
||||||
|
onchange="var l=this.closest('.btn-file'),s=l.querySelector('[data-file-label]');s.textContent=this.files.length?this.files[0].name:'.torrent';l.classList.toggle('has-file',!!this.files.length)">
|
||||||
|
</label>
|
||||||
<button class="btn btn-primary btn-lg" type="submit">Добавить</button>
|
<button class="btn btn-primary btn-lg" type="submit">Добавить</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="add-hint">Можно вставить целиком сообщение трекер-бота — magnet и контекст разберутся автоматически.</p>
|
<p class="add-hint">Вставь magnet/ссылку или сообщение трекер-бота, либо выбери <code>.torrent</code>-файл — контекст разберётся автоматически.</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{{if or .Downloads .Searching}}
|
{{if or .Downloads .Searching}}
|
||||||
|
|||||||
Reference in New Issue
Block a user