Compare commits
15 commits
b1673151f9
...
368bc27eaa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
368bc27eaa | ||
|
|
425ced2c45 | ||
|
|
02afe80d91 | ||
|
|
7cf20e00c6 | ||
|
|
390c8412e3 | ||
|
|
746afb2f24 | ||
|
|
e4265fa777 | ||
|
|
a6e1bc21d7 | ||
|
|
bba337ee60 | ||
|
|
6bb73ffb6e | ||
|
|
20d05be69f | ||
|
|
ebba1d5b61 | ||
|
|
27bb592659 | ||
|
|
9978610cae | ||
|
|
cf79db0932 |
36 changed files with 10774 additions and 2 deletions
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Binaries
|
||||||
|
/ztrr
|
||||||
|
/ztorrent*
|
||||||
|
/torrent-client*
|
||||||
|
*.exe
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
*.out
|
||||||
|
bin/
|
||||||
|
cmd/ztrr/ztrr
|
||||||
|
|
||||||
|
# Logs and databases
|
||||||
|
*.log
|
||||||
|
/logs/
|
||||||
|
/downloads/
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Temporary and build files
|
||||||
|
/dist/
|
||||||
|
/tmp/
|
||||||
|
*.test
|
||||||
|
*.prof
|
||||||
|
.ztorrent-parts/
|
||||||
|
profile.txt
|
||||||
|
testlip.go
|
||||||
|
|
||||||
|
# JSON config files
|
||||||
|
*.json
|
||||||
86
README.md
86
README.md
|
|
@ -1,3 +1,85 @@
|
||||||
# Ztorrent
|
# ZTORRENT ⚡
|
||||||
|
|
||||||
ну нихуя себе, как это великая ZOV OS и без своего торрент клиента, qbittorrent сосиииииииииииииииииииии
|
**Ztorrent** — это минималистичный и невероятно быстрый консольный (TUI) BitTorrent-клиент, написанный на Go.
|
||||||
|
|
||||||
|
Он работает прямо в вашем терминале, поддерживает `.torrent` файлы и **magnet-ссылки**, умеет искать пиров через собственный встроенный **DHT-движок** и сохраняет историю всех ваших загрузок и логов.
|
||||||
|
|
||||||
|
 *(Пример интерфейса — скриншот будет добавлен позже)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔥 Ключевые возможности
|
||||||
|
|
||||||
|
- 🚀 **Полноценный TUI (Terminal UI)** — стильный и отзывчивый интерфейс прямо в консоли (написан на BubbleTea).
|
||||||
|
- 🧲 **Поддержка Magnet-ссылок** — загрузка метаданных "на лету" через встроенный DHT-сервер и UDP-трекеры.
|
||||||
|
- 📁 **Классические `.torrent` файлы** — парсинг однофайловых и многофайловых торрентов, поддержка огромных раздач.
|
||||||
|
- 💾 **Журнал торрентов (History)** — программа запоминает всё, что вы скачивали, и позволяет быстро перезапустить старую раздачу.
|
||||||
|
- 📝 **Логирование сессий** — подробные логи каждого скачивания аккуратно сохраняются в Markdown-файлы (`~/.ztorrent/logs/`).
|
||||||
|
- ⚡ **Мультитрекерность** — автоматический опрос всех доступных трекеров по HTTP/HTTPS и UDP.
|
||||||
|
- 🖥 **Кроссплатформенность** — работает на macOS, Linux и Windows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠 Установка (Инструкция)
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
Для сборки из исходников вам понадобится установленный [Go (версии 1.21+)](https://go.dev/dl/).
|
||||||
|
|
||||||
|
### Шаг 1: Клонирование репозитория
|
||||||
|
Скачайте исходный код к себе на компьютер:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/твоё_имя/ztorrent.git
|
||||||
|
cd ztorrent
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 2: Сборка программы
|
||||||
|
Соберите бинарный файл `ztrr` стандартной командой Go. (Мы используем директорию `cmd/ztrr` как точку входа):
|
||||||
|
```bash
|
||||||
|
go build -o ztrr ./cmd/ztrr
|
||||||
|
```
|
||||||
|
|
||||||
|
*Дополнительно: если хотите вшить версию и дату сборки (отображается по кнопке `?`), используйте эту команду:*
|
||||||
|
```bash
|
||||||
|
VERSION="1.0.0" && BUILDDATE=$(date -u +%Y-%m-%d) && COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") && go build -ldflags="-X 'github.com/veggiedefender/torrent-client/internal/version.Version=${VERSION}' -X 'github.com/veggiedefender/torrent-client/internal/version.BuildDate=${BUILDDATE}' -X 'github.com/veggiedefender/torrent-client/internal/version.GitCommit=${COMMIT}'" -o ztrr ./cmd/ztrr
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 3: Глобальная установка (Опционально)
|
||||||
|
Чтобы запускать торрент-клиент из любой папки, перенесите собранный файл в `/usr/local/bin` (или в любую папку из вашего `$PATH`):
|
||||||
|
```bash
|
||||||
|
sudo mv ztrr /usr/local/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
Теперь вы можете запустить клиент просто написав в терминале:
|
||||||
|
```bash
|
||||||
|
ztrr
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎮 Использование и Горячие клавиши
|
||||||
|
|
||||||
|
После запуска программы перед вами откроется экран приветствия.
|
||||||
|
Вы можете ввести путь к `.torrent` файлу вручную, либо нажать **`F`**, чтобы выбрать его через системный проводник. Также поддерживается вставка magnet-ссылки прямо в текстовое поле.
|
||||||
|
|
||||||
|
### Горячие клавиши (Доступны отовсюду):
|
||||||
|
- `J` или `5` — **Журнал торрентов**: список ранее скачанных торрентов.
|
||||||
|
- `L` или `6` — **Журнал логов**: подробные технические логи (помогают понять, почему торрент не качается).
|
||||||
|
- `?` или `7` — **О программе**: версия, дата сборки и список всех хоткеев.
|
||||||
|
- `Q` или `Ctrl+C` — Выйти из программы.
|
||||||
|
|
||||||
|
### Во время скачивания (Дашборд):
|
||||||
|
- `1-4` / `Tab` — Переключение вкладок (Обзор, Пиры, Файлы, Трекеры).
|
||||||
|
- `Пробел` или `S` — Пауза / Продолжить скачивание.
|
||||||
|
- `O` — Закрыть текущий торрент и открыть окно нового.
|
||||||
|
- `C` — Очистить всё.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📂 Где хранятся данные?
|
||||||
|
|
||||||
|
Все служебные данные Ztorrent хранит в вашей домашней директории:
|
||||||
|
- `~/.ztorrent/history.json` — база данных ваших загрузок (Журнал).
|
||||||
|
- `~/.ztorrent/logs/` — markdown-файлы с логами для каждой сессии скачивания.
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
Ztorrent распространяется под лицензией MIT. Вы можете свободно изменять и распространять код.
|
||||||
|
|
|
||||||
47
TODO.md
Normal file
47
TODO.md
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# ZTORRENT — ROADMAP & TODO
|
||||||
|
|
||||||
|
> **Глобальная цель:** Довести Ztorrent до продакшен-уровня (стандарты qBittorrent и выше), сохранив консольную эстетику.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Глобальный план развития (Roadmap)
|
||||||
|
|
||||||
|
### 1. Движок уровня qBittorrent (Core Engine)
|
||||||
|
- [ ] **Оптимизация I/O:** Асинхронная запись на диск (mmap / AIO), чтобы не было фризов UI при скачивании на гигабитных скоростях.
|
||||||
|
- [ ] **Продвинутый кэш:** Умное кэширование кусков в ОЗУ перед сбросом на диск.
|
||||||
|
- [ ] **Smart Ban / Anti-Leech:** Автоматический бан плохих пиров, защита от спам-хэшей и некорректных кусков.
|
||||||
|
- [ ] **Приоритизация файлов:** Возможность выбирать, какие файлы из раздачи качать первыми.
|
||||||
|
|
||||||
|
### 2. Идеальный DHT
|
||||||
|
- [ ] **BEP 44 (Arbitrary Data):** Хранение произвольных данных в DHT.
|
||||||
|
- [ ] **Умный роутинг (Kademlia 2.0):** Динамическое поддержание "живых" нод в bucket'ах, пинги в фоне.
|
||||||
|
- [ ] **IPv6 DHT:** Поддержка поиска пиров в IPv6 сетях.
|
||||||
|
- [ ] **Локальный поиск (LSD):** Поиск пиров в локальной сети (Local Service Discovery).
|
||||||
|
|
||||||
|
### 3. Умное логирование (Smart Logging)
|
||||||
|
- [ ] **Уровни логов (Levels):** Переход на структурированные логи (DEBUG, INFO, WARN, ERROR).
|
||||||
|
- [ ] **Фильтрация логов в UI:** Возможность прямо в TUI отфильтровать логи по "DHT", "Peers", "Disk".
|
||||||
|
- [ ] **Аналитика сессии:** Summary по итогам скачивания (сколько отброшено кусков, средняя скорость, топовые пиры).
|
||||||
|
|
||||||
|
### 4. Эргономика UI (UX Refactoring)
|
||||||
|
- [ ] **Интерактивные таблицы:** Возможность сортировки по столбцам (по скорости, проценту скачивания).
|
||||||
|
- [ ] **Просмотр файлов внутри торрента:** Древовидная структура (`tree view`) для многофайловых торрентов.
|
||||||
|
- [ ] **Настройки "на лету":** Вызов окна настроек (`Config`) прямо из UI (лимиты скорости, порты, пути).
|
||||||
|
- [ ] **Визуализация Swarm'а:** Красивая карта пиров или график скорости (ASCII Sparklines).
|
||||||
|
|
||||||
|
### 5. Killer-Features (Убийцы qBit)
|
||||||
|
- [ ] **Стриминг в плеер (VLC/mpv):** Скачивание торрента "на лету" (куски по порядку) с пробросом HTTP-стрима прямо в медиаплеер (нажал кнопку — фильм сразу открылся).
|
||||||
|
- [ ] **Интеграция с Telegram / Discord:** Уведомления об окончании загрузки прямо на телефон.
|
||||||
|
- [ ] **CLI Remote Control:** Возможность поднять Ztorrent как демона на сервере и управлять им через SSH/TUI удалённо по RPC.
|
||||||
|
- [ ] **Web-виджет (Read-Only):** Поднятие легковесного веб-сервера для просмотра статуса загрузок с телефона.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠 Выполнено (Архив)
|
||||||
|
|
||||||
|
- ✅ **Magnet-ссылки (BEP 9)** и метаданные.
|
||||||
|
- ✅ **Базовый DHT (BEP 5)** с непрерывным поиском.
|
||||||
|
- ✅ **Мультитрекерность** (HTTP + UDP).
|
||||||
|
- ✅ **Resume Data / Endgame Mode / Keep-alive.**
|
||||||
|
- ✅ **Журнал (History) и Логи (Session Logs).**
|
||||||
|
- ✅ **TUI интерфейс** с глобальными оверлеями (О программе, Журнал, Логи).
|
||||||
39
cmd/ztrr/main.go
Normal file
39
cmd/ztrr/main.go
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
_ "net/http/pprof"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/veggiedefender/torrent-client/internal/app"
|
||||||
|
"github.com/veggiedefender/torrent-client/internal/config"
|
||||||
|
"github.com/veggiedefender/torrent-client/internal/tgbot"
|
||||||
|
"github.com/veggiedefender/torrent-client/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
go func() {
|
||||||
|
log.Println(http.ListenAndServe("localhost:6060", nil))
|
||||||
|
}()
|
||||||
|
|
||||||
|
controller := app.NewController()
|
||||||
|
|
||||||
|
cfg, _ := config.Load()
|
||||||
|
var token string
|
||||||
|
if cfg != nil {
|
||||||
|
token = cfg.TelegramToken
|
||||||
|
}
|
||||||
|
if envTok := os.Getenv("TG_BOT_TOKEN"); envTok != "" {
|
||||||
|
token = envTok
|
||||||
|
}
|
||||||
|
|
||||||
|
bot, err := tgbot.NewBot(controller, token)
|
||||||
|
if err != nil {
|
||||||
|
// Log but don't fail, bot is optional
|
||||||
|
} else if bot != nil {
|
||||||
|
bot.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.Start(controller)
|
||||||
|
}
|
||||||
41
go.mod
Normal file
41
go.mod
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
module github.com/veggiedefender/torrent-client
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||||
|
github.com/huin/goupnp v1.3.0
|
||||||
|
github.com/jackpal/bencode-go v1.0.2
|
||||||
|
golang.org/x/time v0.15.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||||
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
golang.org/x/sync v0.11.0 // indirect
|
||||||
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
|
golang.org/x/text v0.22.0 // indirect
|
||||||
|
)
|
||||||
67
go.sum
Normal file
67
go.sum
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||||
|
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||||
|
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
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/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
|
||||||
|
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
|
||||||
|
github.com/jackpal/bencode-go v1.0.2 h1:LcCNfZ344u0LpBPOZNjpCLps/wUOuN4r87Fy9+5yU8g=
|
||||||
|
github.com/jackpal/bencode-go v1.0.2/go.mod h1:6jI9mUjO3GQbZti3JizEfxTzRfWOM8oBBcwbwlTfceI=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
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-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||||
|
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
144
internal/app/controller.go
Normal file
144
internal/app/controller.go
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/veggiedefender/torrent-client/internal/config"
|
||||||
|
"github.com/veggiedefender/torrent-client/internal/history"
|
||||||
|
"github.com/veggiedefender/torrent-client/internal/tgbot"
|
||||||
|
"github.com/veggiedefender/torrent-client/internal/torrent"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Controller struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
engine *torrent.Engine
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
currentPath string
|
||||||
|
currentOut string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewController() *Controller {
|
||||||
|
engine := torrent.NewEngine()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
return &Controller{
|
||||||
|
engine: engine,
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) StartTorrent(path, outputRoot string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.cancel != nil {
|
||||||
|
c.cancel()
|
||||||
|
}
|
||||||
|
c.ctx, c.cancel = context.WithCancel(context.Background())
|
||||||
|
c.currentPath = path
|
||||||
|
c.currentOut = outputRoot
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
err := c.engine.LoadTorrent(path, outputRoot)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.updateHistory()
|
||||||
|
|
||||||
|
go func(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
c.updateHistory()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(c.ctx)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) DownloadMagnet(magnetURI string) error {
|
||||||
|
cfg, _ := config.Load()
|
||||||
|
outDir := "."
|
||||||
|
if cfg != nil && cfg.DefaultDir != "" {
|
||||||
|
outDir = cfg.DefaultDir
|
||||||
|
}
|
||||||
|
return c.StartTorrent(magnetURI, outDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) InitTelegramBot(token string) error {
|
||||||
|
if token == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
bot, err := tgbot.NewBot(c, token)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if bot != nil {
|
||||||
|
bot.Start()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) StopTorrent() {
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.cancel != nil {
|
||||||
|
c.cancel()
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.updateHistory()
|
||||||
|
c.engine.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) updateHistory() {
|
||||||
|
status := c.engine.Status()
|
||||||
|
if status.Phase == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hashHex := ""
|
||||||
|
if len(status.InfoHash) > 0 {
|
||||||
|
hashHex = hex.EncodeToString(status.InfoHash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
path := c.currentPath
|
||||||
|
out := c.currentOut
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
hItem := history.Item{
|
||||||
|
InfoHash: hashHex,
|
||||||
|
Name: status.Name,
|
||||||
|
TorrentPath: path,
|
||||||
|
OutputDir: out,
|
||||||
|
Status: status.Phase,
|
||||||
|
Progress: c.engine.Progress() * 100, // store as percentage 0-100
|
||||||
|
Size: status.TotalBytes,
|
||||||
|
}
|
||||||
|
history.Update(hItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) Progress() float64 {
|
||||||
|
return c.engine.Progress()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) Status() torrent.Status {
|
||||||
|
if c.engine == nil {
|
||||||
|
return torrent.Status{}
|
||||||
|
}
|
||||||
|
return c.engine.Status()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) ToggleFilePriority(fileIdx int) {
|
||||||
|
if c.engine != nil {
|
||||||
|
c.engine.ToggleFilePriority(fileIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
79
internal/config/config.go
Normal file
79
internal/config/config.go
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
TelegramToken string `json:"telegram_token"`
|
||||||
|
TelegramOwnerID int64 `json:"telegram_owner_id"`
|
||||||
|
DefaultDir string `json:"default_dir"`
|
||||||
|
DownloadLimit int `json:"download_limit_kbs"` // 0 = no limit
|
||||||
|
UploadLimit int `json:"upload_limit_kbs"` // 0 = no limit
|
||||||
|
DisableAnimations bool `json:"disable_animations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
func getConfigPath() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".ztrr", "config.json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
path, err := getConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return &Config{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Save(cfg *Config) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
path, err := getConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpPath := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.Rename(tmpPath, path)
|
||||||
|
}
|
||||||
394
internal/dht/dht.go
Normal file
394
internal/dht/dht.go
Normal file
|
|
@ -0,0 +1,394 @@
|
||||||
|
package dht
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/veggiedefender/torrent-client/internal/logger"
|
||||||
|
"github.com/veggiedefender/torrent-client/internal/tracker"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MaxNodes = 8
|
||||||
|
Port = 6881
|
||||||
|
ReadBufSize = 65536
|
||||||
|
Alpha = 5 // параллельных запросов
|
||||||
|
)
|
||||||
|
|
||||||
|
// Много bootstrap-нод для надёжности
|
||||||
|
var BootstrapNodes = []string{
|
||||||
|
"router.bittorrent.com:6881",
|
||||||
|
"dht.transmissionbt.com:6881",
|
||||||
|
"router.utorrent.com:6881",
|
||||||
|
"dht.aelitis.com:6881",
|
||||||
|
"bootstrap.jami.net:4222",
|
||||||
|
"router.silotis.us:6881",
|
||||||
|
"dht.libtorrent.org:25401",
|
||||||
|
}
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
ID NodeID
|
||||||
|
conn *net.UDPConn
|
||||||
|
routingTable *RoutingTable
|
||||||
|
|
||||||
|
transactionsMu sync.Mutex
|
||||||
|
transactions map[string]chan Msg
|
||||||
|
|
||||||
|
PeersFound chan []tracker.Peer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer() *Server {
|
||||||
|
id := RandomNodeID()
|
||||||
|
return &Server{
|
||||||
|
ID: id,
|
||||||
|
routingTable: NewRoutingTable(id),
|
||||||
|
transactions: make(map[string]chan Msg),
|
||||||
|
PeersFound: make(chan []tracker.Peer, 100),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Start(ctx context.Context, port int) error {
|
||||||
|
addr, err := net.ResolveUDPAddr("udp", fmt.Sprintf(":%d", port))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := net.ListenUDP("udp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.conn = conn
|
||||||
|
|
||||||
|
go s.readLoop(ctx)
|
||||||
|
go s.bootstrap(ctx)
|
||||||
|
|
||||||
|
logger.Info("DHT", "DHT Server listening on %s with ID %x", conn.LocalAddr(), s.ID[:8])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) readLoop(ctx context.Context) {
|
||||||
|
buf := make([]byte, ReadBufSize)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
s.conn.Close()
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
s.conn.SetReadDeadline(time.Now().Add(1 * time.Second))
|
||||||
|
n, from, err := s.conn.ReadFromUDP(buf)
|
||||||
|
if err != nil {
|
||||||
|
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "use of closed network connection") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := DecodeMsg(buf[:n])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
s.handleMsg(msg, from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMsg(msg Msg, from *net.UDPAddr) {
|
||||||
|
var senderID NodeID
|
||||||
|
var ok bool
|
||||||
|
if msg.Y == "q" && msg.A != nil {
|
||||||
|
if id, isStr := msg.A["id"].(string); isStr && len(id) == 20 {
|
||||||
|
copy(senderID[:], id)
|
||||||
|
ok = true
|
||||||
|
}
|
||||||
|
} else if msg.Y == "r" && msg.R != nil {
|
||||||
|
if id, isStr := msg.R["id"].(string); isStr && len(id) == 20 {
|
||||||
|
copy(senderID[:], id)
|
||||||
|
ok = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
s.routingTable.AddNode(Node{ID: senderID, Addr: from})
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.Y == "r" || msg.Y == "e" {
|
||||||
|
s.transactionsMu.Lock()
|
||||||
|
ch, exists := s.transactions[msg.T]
|
||||||
|
s.transactionsMu.Unlock()
|
||||||
|
if exists {
|
||||||
|
select {
|
||||||
|
case ch <- msg:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.Y == "q" {
|
||||||
|
switch msg.Q {
|
||||||
|
case "ping":
|
||||||
|
s.sendResponse(from, msg.T, map[string]interface{}{
|
||||||
|
"id": string(s.ID[:]),
|
||||||
|
})
|
||||||
|
case "find_node":
|
||||||
|
targetStr, _ := msg.A["target"].(string)
|
||||||
|
if len(targetStr) == 20 {
|
||||||
|
var target NodeID
|
||||||
|
copy(target[:], targetStr)
|
||||||
|
nodes := s.routingTable.ClosestNodes(target, MaxNodes)
|
||||||
|
s.sendResponse(from, msg.T, map[string]interface{}{
|
||||||
|
"id": string(s.ID[:]),
|
||||||
|
"nodes": encodeNodes(nodes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case "get_peers":
|
||||||
|
infoHashStr, _ := msg.A["info_hash"].(string)
|
||||||
|
if len(infoHashStr) == 20 {
|
||||||
|
var target NodeID
|
||||||
|
copy(target[:], infoHashStr)
|
||||||
|
nodes := s.routingTable.ClosestNodes(target, MaxNodes)
|
||||||
|
s.sendResponse(from, msg.T, map[string]interface{}{
|
||||||
|
"id": string(s.ID[:]),
|
||||||
|
"token": "token",
|
||||||
|
"nodes": encodeNodes(nodes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) sendQuery(ctx context.Context, addr *net.UDPAddr, q string, a map[string]interface{}) (Msg, error) {
|
||||||
|
var tid string
|
||||||
|
s.transactionsMu.Lock()
|
||||||
|
for {
|
||||||
|
tidBytes := make([]byte, 2)
|
||||||
|
rand.Read(tidBytes)
|
||||||
|
tid = string(tidBytes)
|
||||||
|
if _, exists := s.transactions[tid]; !exists {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ch := make(chan Msg, 1)
|
||||||
|
s.transactions[tid] = ch
|
||||||
|
s.transactionsMu.Unlock()
|
||||||
|
|
||||||
|
a["id"] = string(s.ID[:])
|
||||||
|
msg := NewQuery(tid, q, a)
|
||||||
|
encoded, err := EncodeMsg(msg)
|
||||||
|
if err != nil {
|
||||||
|
return Msg{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
s.transactionsMu.Lock()
|
||||||
|
delete(s.transactions, tid)
|
||||||
|
s.transactionsMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if _, err := s.conn.WriteToUDP(encoded, addr); err != nil {
|
||||||
|
return Msg{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return Msg{}, ctx.Err()
|
||||||
|
case resp := <-ch:
|
||||||
|
if resp.Y == "e" {
|
||||||
|
return Msg{}, fmt.Errorf("KRPC error: %v", resp.E)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
return Msg{}, fmt.Errorf("timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) sendResponse(addr *net.UDPAddr, tid string, r map[string]interface{}) {
|
||||||
|
msg := Msg{T: tid, Y: "r", R: r}
|
||||||
|
encoded, err := EncodeMsg(msg)
|
||||||
|
if err == nil {
|
||||||
|
s.conn.WriteToUDP(encoded, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) bootstrap(ctx context.Context) {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, addrStr := range BootstrapNodes {
|
||||||
|
addr, err := net.ResolveUDPAddr("udp", addrStr)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
go func(a *net.UDPAddr, name string) {
|
||||||
|
defer wg.Done()
|
||||||
|
qctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
resp, err := s.sendQuery(qctx, a, "find_node", map[string]interface{}{
|
||||||
|
"target": string(s.ID[:]),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.Info("DHT", "DHT bootstrap %s failed: %v", name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resp.R != nil {
|
||||||
|
if nodesStr, ok := resp.R["nodes"].(string); ok {
|
||||||
|
count := len(nodesStr) / 26
|
||||||
|
logger.Info("DHT", "DHT bootstrap %s: got %d nodes", name, count)
|
||||||
|
s.parseAndAddNodes(nodesStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(addr, addrStr)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
logger.Info("DHT", "DHT bootstrap done, routing table: %d nodes", s.routingTable.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchForPeers непрерывно ищет пиров для данного info_hash.
|
||||||
|
// Не прекращает поиск, пока контекст не отменён.
|
||||||
|
func (s *Server) SearchForPeers(ctx context.Context, infoHash [20]byte) {
|
||||||
|
// Ждём bootstrap
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
if s.routingTable.Len() > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
}
|
||||||
|
logger.Info("DHT", "DHT SearchForPeers starting, routing table: %d nodes", s.routingTable.Len())
|
||||||
|
|
||||||
|
targetID := NodeID(infoHash)
|
||||||
|
queried := make(map[string]bool) // ключ = IP:port строка
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
closest := s.routingTable.ClosestNodes(targetID, MaxNodes*4)
|
||||||
|
var toQuery []Node
|
||||||
|
for _, n := range closest {
|
||||||
|
key := n.Addr.String()
|
||||||
|
if !queried[key] {
|
||||||
|
toQuery = append(toQuery, n)
|
||||||
|
queried[key] = true
|
||||||
|
if len(toQuery) >= Alpha {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(toQuery) == 0 {
|
||||||
|
logger.Info("DHT", "DHT: no new nodes to query (%d total queried), waiting before retry", len(queried))
|
||||||
|
queried = make(map[string]bool)
|
||||||
|
if s.routingTable.Len() == 0 {
|
||||||
|
go s.bootstrap(ctx)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, n := range toQuery {
|
||||||
|
go func(node Node) {
|
||||||
|
qctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
resp, err := s.sendQuery(qctx, node.Addr, "get_peers", map[string]interface{}{
|
||||||
|
"info_hash": string(infoHash[:]),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resp.R == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Добавляем новые ноды в routing table
|
||||||
|
if nodesStr, ok := resp.R["nodes"].(string); ok {
|
||||||
|
s.parseAndAddNodes(nodesStr)
|
||||||
|
}
|
||||||
|
// Пробуем извлечь пиров
|
||||||
|
if values, ok := resp.R["values"].([]interface{}); ok {
|
||||||
|
var allPeerData []byte
|
||||||
|
for _, v := range values {
|
||||||
|
if peerStr, ok := v.(string); ok {
|
||||||
|
allPeerData = append(allPeerData, []byte(peerStr)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if peers, err := tracker.ParsePeers(allPeerData); err == nil && len(peers) > 0 {
|
||||||
|
logger.Info("DHT", "DHT: found %d peers from %s", len(peers), node.Addr)
|
||||||
|
select {
|
||||||
|
case s.PeersFound <- peers:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeNodes(nodes []Node) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
for _, n := range nodes {
|
||||||
|
buf.Write(n.ID[:])
|
||||||
|
if v4 := n.Addr.IP.To4(); v4 != nil {
|
||||||
|
buf.Write(v4)
|
||||||
|
} else {
|
||||||
|
buf.Write(net.IPv4zero)
|
||||||
|
}
|
||||||
|
portBuf := make([]byte, 2)
|
||||||
|
portBuf[0] = byte(n.Addr.Port >> 8)
|
||||||
|
portBuf[1] = byte(n.Addr.Port)
|
||||||
|
buf.Write(portBuf)
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) parseAndAddNodes(nodesStr string) {
|
||||||
|
data := []byte(nodesStr)
|
||||||
|
for i := 0; i+26 <= len(data); i += 26 {
|
||||||
|
var id NodeID
|
||||||
|
copy(id[:], data[i:i+20])
|
||||||
|
ip := net.IP(data[i+20 : i+24])
|
||||||
|
port := int(data[i+24])<<8 | int(data[i+25])
|
||||||
|
if port == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.routingTable.AddNode(Node{
|
||||||
|
ID: id,
|
||||||
|
Addr: &net.UDPAddr{
|
||||||
|
IP: ip,
|
||||||
|
Port: port,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HexToNodeID(h string) NodeID {
|
||||||
|
var id NodeID
|
||||||
|
b, _ := hex.DecodeString(h)
|
||||||
|
copy(id[:], b)
|
||||||
|
return id
|
||||||
|
}
|
||||||
84
internal/dht/krpc.go
Normal file
84
internal/dht/krpc.go
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
package dht
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackpal/bencode-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Msg represents a KRPC message.
|
||||||
|
type Msg struct {
|
||||||
|
T string `bencode:"t"`
|
||||||
|
Y string `bencode:"y"`
|
||||||
|
Q string `bencode:"q,omitempty"`
|
||||||
|
A map[string]interface{} `bencode:"a,omitempty"`
|
||||||
|
R map[string]interface{} `bencode:"r,omitempty"`
|
||||||
|
E []interface{} `bencode:"e,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeMsg marshals a KRPC message to bencode.
|
||||||
|
func EncodeMsg(msg Msg) ([]byte, error) {
|
||||||
|
m := make(map[string]interface{})
|
||||||
|
m["t"] = msg.T
|
||||||
|
m["y"] = msg.Y
|
||||||
|
if msg.Q != "" {
|
||||||
|
m["q"] = msg.Q
|
||||||
|
}
|
||||||
|
if msg.A != nil {
|
||||||
|
m["a"] = msg.A
|
||||||
|
}
|
||||||
|
if msg.R != nil {
|
||||||
|
m["r"] = msg.R
|
||||||
|
}
|
||||||
|
if msg.E != nil {
|
||||||
|
m["e"] = msg.E
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := bencode.Marshal(&buf, m)
|
||||||
|
return buf.Bytes(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeMsg unmarshals a KRPC message from bencode.
|
||||||
|
func DecodeMsg(data []byte) (Msg, error) {
|
||||||
|
val, err := bencode.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return Msg{}, err
|
||||||
|
}
|
||||||
|
m, ok := val.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return Msg{}, fmt.Errorf("expected map[string]interface{}, got %T", val)
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg Msg
|
||||||
|
if t, ok := m["t"].(string); ok {
|
||||||
|
msg.T = t
|
||||||
|
}
|
||||||
|
if y, ok := m["y"].(string); ok {
|
||||||
|
msg.Y = y
|
||||||
|
}
|
||||||
|
if q, ok := m["q"].(string); ok {
|
||||||
|
msg.Q = q
|
||||||
|
}
|
||||||
|
if a, ok := m["a"].(map[string]interface{}); ok {
|
||||||
|
msg.A = a
|
||||||
|
}
|
||||||
|
if r, ok := m["r"].(map[string]interface{}); ok {
|
||||||
|
msg.R = r
|
||||||
|
}
|
||||||
|
if e, ok := m["e"].([]interface{}); ok {
|
||||||
|
msg.E = e
|
||||||
|
}
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewQuery creates a KRPC query message.
|
||||||
|
func NewQuery(t string, q string, a map[string]interface{}) Msg {
|
||||||
|
return Msg{
|
||||||
|
T: t,
|
||||||
|
Y: "q",
|
||||||
|
Q: q,
|
||||||
|
A: a,
|
||||||
|
}
|
||||||
|
}
|
||||||
48
internal/dht/krpc_test.go
Normal file
48
internal/dht/krpc_test.go
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
package dht
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestKRPCParsing(t *testing.T) {
|
||||||
|
msg := NewQuery("aa", "ping", map[string]interface{}{"id": "abcdefghij0123456789"})
|
||||||
|
encoded, err := EncodeMsg(msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to encode msg: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := DecodeMsg(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to decode msg: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if decoded.T != "aa" {
|
||||||
|
t.Errorf("Expected T='aa', got '%s'", decoded.T)
|
||||||
|
}
|
||||||
|
if decoded.Y != "q" {
|
||||||
|
t.Errorf("Expected Y='q', got '%s'", decoded.Y)
|
||||||
|
}
|
||||||
|
if decoded.Q != "ping" {
|
||||||
|
t.Errorf("Expected Q='ping', got '%s'", decoded.Q)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, ok := decoded.A["id"].(string)
|
||||||
|
if !ok || id != "abcdefghij0123456789" {
|
||||||
|
t.Errorf("Expected A['id']='abcdefghij0123456789', got '%v'", decoded.A["id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeDecodeNodes(t *testing.T) {
|
||||||
|
// Simple test to ensure encodeNodes and parseAndAddNodes don't panic
|
||||||
|
s := NewServer()
|
||||||
|
var nodes []Node
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
id := RandomNodeID()
|
||||||
|
nodes = append(nodes, Node{ID: id, Addr: nil})
|
||||||
|
}
|
||||||
|
// We can't easily test the exact binary encoding here without setting up IPs,
|
||||||
|
// but the function exists and was successfully compiled.
|
||||||
|
if s == nil {
|
||||||
|
t.Fatal("Server should not be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
153
internal/dht/routing.go
Normal file
153
internal/dht/routing.go
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
package dht
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NodeID is a 160-bit Kademlia ID.
|
||||||
|
type NodeID [20]byte
|
||||||
|
|
||||||
|
// RandomNodeID generates a new random NodeID.
|
||||||
|
func RandomNodeID() NodeID {
|
||||||
|
var id NodeID
|
||||||
|
_, _ = rand.Read(id[:])
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node represents a contact in the DHT network.
|
||||||
|
type Node struct {
|
||||||
|
ID NodeID
|
||||||
|
Addr *net.UDPAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distance calculates the XOR distance between two NodeIDs.
|
||||||
|
func Distance(a, b NodeID) *big.Int {
|
||||||
|
var xor [20]byte
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
xor[i] = a[i] ^ b[i]
|
||||||
|
}
|
||||||
|
return new(big.Int).SetBytes(xor[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bucket represents a Kademlia k-bucket.
|
||||||
|
type Bucket struct {
|
||||||
|
nodes []Node
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoutingTable manages known nodes using Kademlia k-buckets.
|
||||||
|
type RoutingTable struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
ownID NodeID
|
||||||
|
buckets [160]*Bucket
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRoutingTable creates a new routing table.
|
||||||
|
func NewRoutingTable(ownID NodeID) *RoutingTable {
|
||||||
|
rt := &RoutingTable{
|
||||||
|
ownID: ownID,
|
||||||
|
}
|
||||||
|
for i := 0; i < 160; i++ {
|
||||||
|
rt.buckets[i] = &Bucket{nodes: make([]Node, 0, MaxNodes)}
|
||||||
|
}
|
||||||
|
return rt
|
||||||
|
}
|
||||||
|
|
||||||
|
// bucketIndex calculates the appropriate bucket index for a given node ID.
|
||||||
|
// Returns an index from 0 to 159, or -1 if the ID is our own.
|
||||||
|
func (rt *RoutingTable) bucketIndex(target NodeID) int {
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
xor := rt.ownID[i] ^ target[i]
|
||||||
|
if xor != 0 {
|
||||||
|
// Find the most significant bit set in the byte
|
||||||
|
for j := 7; j >= 0; j-- {
|
||||||
|
if (xor & (1 << j)) != 0 {
|
||||||
|
return 159 - (i*8 + (7 - j))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddNode adds a node to the routing table or updates it.
|
||||||
|
func (rt *RoutingTable) AddNode(n Node) {
|
||||||
|
idx := rt.bucketIndex(n.ID)
|
||||||
|
if idx == -1 {
|
||||||
|
return // Do not add ourselves
|
||||||
|
}
|
||||||
|
|
||||||
|
rt.mu.Lock()
|
||||||
|
defer rt.mu.Unlock()
|
||||||
|
|
||||||
|
bucket := rt.buckets[idx]
|
||||||
|
|
||||||
|
// Check if already exists and update
|
||||||
|
for i, existing := range bucket.nodes {
|
||||||
|
if existing.ID == n.ID {
|
||||||
|
bucket.nodes[i].Addr = n.Addr
|
||||||
|
// Move to end (most recently seen)
|
||||||
|
node := bucket.nodes[i]
|
||||||
|
bucket.nodes = append(bucket.nodes[:i], bucket.nodes[i+1:]...)
|
||||||
|
bucket.nodes = append(bucket.nodes, node)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new node if bucket is not full
|
||||||
|
if len(bucket.nodes) < MaxNodes {
|
||||||
|
bucket.nodes = append(bucket.nodes, n)
|
||||||
|
} else {
|
||||||
|
// In a full Kademlia implementation, we would ping the oldest node
|
||||||
|
// and replace it if it doesn't respond. For now, just drop the new one.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClosestNodes returns the closest nodes to a given target ID.
|
||||||
|
func (rt *RoutingTable) ClosestNodes(target NodeID, count int) []Node {
|
||||||
|
rt.mu.RLock()
|
||||||
|
defer rt.mu.RUnlock()
|
||||||
|
|
||||||
|
var allNodes []Node
|
||||||
|
|
||||||
|
// Fast path: find the target's bucket
|
||||||
|
idx := rt.bucketIndex(target)
|
||||||
|
if idx != -1 {
|
||||||
|
allNodes = append(allNodes, rt.buckets[idx].nodes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect nodes from neighboring buckets if we don't have enough
|
||||||
|
if len(allNodes) < count {
|
||||||
|
// We just collect all nodes and sort them for simplicity.
|
||||||
|
// Optimizing this is possible but not strictly necessary for < 1280 nodes total.
|
||||||
|
allNodes = nil
|
||||||
|
for i := 0; i < 160; i++ {
|
||||||
|
allNodes = append(allNodes, rt.buckets[i].nodes...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(allNodes, func(i, j int) bool {
|
||||||
|
distI := Distance(allNodes[i].ID, target)
|
||||||
|
distJ := Distance(allNodes[j].ID, target)
|
||||||
|
return distI.Cmp(distJ) < 0
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(allNodes) > count {
|
||||||
|
return allNodes[:count]
|
||||||
|
}
|
||||||
|
return allNodes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len returns the total number of nodes in the routing table.
|
||||||
|
func (rt *RoutingTable) Len() int {
|
||||||
|
rt.mu.RLock()
|
||||||
|
defer rt.mu.RUnlock()
|
||||||
|
count := 0
|
||||||
|
for i := 0; i < 160; i++ {
|
||||||
|
count += len(rt.buckets[i].nodes)
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
38
internal/dht/routing_test.go
Normal file
38
internal/dht/routing_test.go
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
package dht
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDistance(t *testing.T) {
|
||||||
|
id1 := NodeID{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}
|
||||||
|
id2 := NodeID{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03}
|
||||||
|
|
||||||
|
// XOR(1, 3) = 2
|
||||||
|
dist := Distance(id1, id2)
|
||||||
|
if dist.Int64() != 2 {
|
||||||
|
t.Errorf("Expected distance 2, got %d", dist.Int64())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutingTableSorts(t *testing.T) {
|
||||||
|
ownID := NodeID{0x00}
|
||||||
|
rt := NewRoutingTable(ownID)
|
||||||
|
|
||||||
|
id1 := NodeID{0x03} // dist 3
|
||||||
|
id2 := NodeID{0x01} // dist 1
|
||||||
|
id3 := NodeID{0x02} // dist 2
|
||||||
|
|
||||||
|
rt.AddNode(Node{ID: id1})
|
||||||
|
rt.AddNode(Node{ID: id2})
|
||||||
|
rt.AddNode(Node{ID: id3})
|
||||||
|
|
||||||
|
closest := rt.ClosestNodes(ownID, 3)
|
||||||
|
if len(closest) != 3 {
|
||||||
|
t.Fatalf("Expected 3 nodes, got %d", len(closest))
|
||||||
|
}
|
||||||
|
|
||||||
|
if closest[0].ID != id2 || closest[1].ID != id3 || closest[2].ID != id1 {
|
||||||
|
t.Errorf("Nodes not sorted correctly: %v", closest)
|
||||||
|
}
|
||||||
|
}
|
||||||
134
internal/history/history.go
Normal file
134
internal/history/history.go
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
package history
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Item represents a single downloaded torrent in history.
|
||||||
|
type Item struct {
|
||||||
|
InfoHash string `json:"info_hash"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
TorrentPath string `json:"torrent_path"` // Magnet link or path to .torrent file
|
||||||
|
OutputDir string `json:"output_dir"`
|
||||||
|
Status string `json:"status"` // e.g. "downloading", "seeding", "stopped", "completed"
|
||||||
|
Progress float64 `json:"progress"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
AddedAt time.Time `json:"added_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// getHistoryPath returns the path to ~/.ztrr/history.json
|
||||||
|
func getHistoryPath() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dir := filepath.Join(home, ".ztrr")
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, "history.json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads all items from the history file.
|
||||||
|
func Load() ([]Item, error) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
path, err := getHistoryPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return []Item{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []Item
|
||||||
|
if err := json.Unmarshal(data, &items); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes all items to the history file.
|
||||||
|
func Save(items []Item) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
path, err := getHistoryPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(items, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(path, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update adds or updates an item in the history.
|
||||||
|
func Update(item Item) error {
|
||||||
|
items, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load history: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for i, existing := range items {
|
||||||
|
if existing.InfoHash == item.InfoHash || (existing.InfoHash == "" && existing.TorrentPath == item.TorrentPath) {
|
||||||
|
// Maintain original added_at if we are updating
|
||||||
|
item.AddedAt = existing.AddedAt
|
||||||
|
if item.InfoHash == "" {
|
||||||
|
item.InfoHash = existing.InfoHash
|
||||||
|
}
|
||||||
|
items[i] = item
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
if item.AddedAt.IsZero() {
|
||||||
|
item.AddedAt = time.Now()
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Save(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove deletes an item from the history.
|
||||||
|
func Remove(infoHash, torrentPath string) error {
|
||||||
|
items, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load history: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var newItems []Item
|
||||||
|
for _, existing := range items {
|
||||||
|
if existing.InfoHash == infoHash && existing.InfoHash != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if existing.TorrentPath == torrentPath && infoHash == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newItems = append(newItems, existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Save(newItems)
|
||||||
|
}
|
||||||
107
internal/logger/logger.go
Normal file
107
internal/logger/logger.go
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Level int
|
||||||
|
|
||||||
|
const (
|
||||||
|
LevelDebug Level = iota
|
||||||
|
LevelInfo
|
||||||
|
LevelWarn
|
||||||
|
LevelError
|
||||||
|
)
|
||||||
|
|
||||||
|
func (l Level) String() string {
|
||||||
|
switch l {
|
||||||
|
case LevelDebug:
|
||||||
|
return "DEBUG"
|
||||||
|
case LevelInfo:
|
||||||
|
return "INFO"
|
||||||
|
case LevelWarn:
|
||||||
|
return "WARN"
|
||||||
|
case LevelError:
|
||||||
|
return "ERROR"
|
||||||
|
default:
|
||||||
|
return "UNKNOWN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Entry struct {
|
||||||
|
Time time.Time
|
||||||
|
Level Level
|
||||||
|
Category string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RingBuffer struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
entries []Entry
|
||||||
|
head int
|
||||||
|
count int
|
||||||
|
size int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRingBuffer(size int) *RingBuffer {
|
||||||
|
return &RingBuffer{
|
||||||
|
entries: make([]Entry, size),
|
||||||
|
size: size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *RingBuffer) Add(e Entry) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
b.entries[b.head] = e
|
||||||
|
b.head = (b.head + 1) % b.size
|
||||||
|
if b.count < b.size {
|
||||||
|
b.count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *RingBuffer) Snapshot() []Entry {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]Entry, b.count)
|
||||||
|
for i := 0; i < b.count; i++ {
|
||||||
|
idx := (b.head - b.count + i + b.size) % b.size
|
||||||
|
result[i] = b.entries[idx]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global logger instance
|
||||||
|
var (
|
||||||
|
GlobalBuffer = NewRingBuffer(1000)
|
||||||
|
)
|
||||||
|
|
||||||
|
func logf(level Level, category string, format string, v ...interface{}) {
|
||||||
|
msg := fmt.Sprintf(format, v...)
|
||||||
|
GlobalBuffer.Add(Entry{
|
||||||
|
Time: time.Now(),
|
||||||
|
Level: level,
|
||||||
|
Category: category,
|
||||||
|
Message: msg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func Debug(category string, format string, v ...interface{}) {
|
||||||
|
logf(LevelDebug, category, format, v...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Info(category string, format string, v ...interface{}) {
|
||||||
|
logf(LevelInfo, category, format, v...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Warn(category string, format string, v ...interface{}) {
|
||||||
|
logf(LevelWarn, category, format, v...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Error(category string, format string, v ...interface{}) {
|
||||||
|
logf(LevelError, category, format, v...)
|
||||||
|
}
|
||||||
83
internal/magnet/magnet.go
Normal file
83
internal/magnet/magnet.go
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
package magnet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base32"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MagnetLink struct {
|
||||||
|
InfoHash [20]byte
|
||||||
|
Name string
|
||||||
|
Trackers []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Parse(uri string) (*MagnetLink, error) {
|
||||||
|
uri = strings.TrimSpace(uri)
|
||||||
|
if !strings.HasPrefix(uri, "magnet:") {
|
||||||
|
return nil, errors.New("invalid magnet link prefix")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(uri, "?", 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return nil, errors.New("magnet link missing query parameters")
|
||||||
|
}
|
||||||
|
|
||||||
|
q, err := url.ParseQuery(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse magnet link query: %w", err)
|
||||||
|
}
|
||||||
|
xts := q["xt"]
|
||||||
|
if len(xts) == 0 {
|
||||||
|
return nil, errors.New("magnet link missing exact topic (xt)")
|
||||||
|
}
|
||||||
|
|
||||||
|
var infoHash [20]byte
|
||||||
|
foundHash := false
|
||||||
|
|
||||||
|
for _, xt := range xts {
|
||||||
|
if strings.HasPrefix(xt, "urn:btih:") {
|
||||||
|
hashStr := strings.TrimPrefix(xt, "urn:btih:")
|
||||||
|
if len(hashStr) == 40 {
|
||||||
|
hashBytes, err := hex.DecodeString(hashStr)
|
||||||
|
if err == nil {
|
||||||
|
copy(infoHash[:], hashBytes)
|
||||||
|
foundHash = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else if len(hashStr) == 32 {
|
||||||
|
// Some magnet links have base32 encoded infohash (Base32 without padding is common, or with padding)
|
||||||
|
// Try standard encoding first, maybe upper/lower case.
|
||||||
|
hashStrUpper := strings.ToUpper(hashStr)
|
||||||
|
hashBytes, err := base32.StdEncoding.DecodeString(hashStrUpper)
|
||||||
|
if err != nil {
|
||||||
|
hashBytes, err = base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(hashStrUpper)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
copy(infoHash[:], hashBytes)
|
||||||
|
foundHash = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundHash {
|
||||||
|
return nil, errors.New("magnet link missing valid BitTorrent info hash (urn:btih:)")
|
||||||
|
}
|
||||||
|
|
||||||
|
ml := &MagnetLink{
|
||||||
|
InfoHash: infoHash,
|
||||||
|
Trackers: q["tr"],
|
||||||
|
}
|
||||||
|
|
||||||
|
dns := q["dn"]
|
||||||
|
if len(dns) > 0 {
|
||||||
|
ml.Name = dns[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return ml, nil
|
||||||
|
}
|
||||||
73
internal/magnet/magnet_test.go
Normal file
73
internal/magnet/magnet_test.go
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
package magnet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseMagnetLink(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
uri string
|
||||||
|
wantName string
|
||||||
|
wantHash string
|
||||||
|
wantTrack int
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid magnet with tracker",
|
||||||
|
uri: "magnet:?xt=urn:btih:3132333435363738393031323334353637383930&dn=ubuntu.iso&tr=http%3A%2F%2Ftracker.com%2Fannounce",
|
||||||
|
wantName: "ubuntu.iso",
|
||||||
|
wantHash: "3132333435363738393031323334353637383930",
|
||||||
|
wantTrack: 1,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid magnet base32",
|
||||||
|
uri: "magnet:?xt=urn:btih:GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&dn=test",
|
||||||
|
wantName: "test",
|
||||||
|
wantHash: "3132333435363738393031323334353637383930",
|
||||||
|
wantTrack: 0,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid prefix",
|
||||||
|
uri: "http://example.com",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing xt",
|
||||||
|
uri: "magnet:?dn=ubuntu.iso",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid hash length",
|
||||||
|
uri: "magnet:?xt=urn:btih:1234&dn=ubuntu",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ml, err := Parse(tt.uri)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("Parse() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
if ml.Name != tt.wantName {
|
||||||
|
t.Errorf("Parse() got name = %v, want %v", ml.Name, tt.wantName)
|
||||||
|
}
|
||||||
|
if gotHash := hex.EncodeToString(ml.InfoHash[:]); gotHash != tt.wantHash {
|
||||||
|
t.Errorf("Parse() got hash = %v, want %v", gotHash, tt.wantHash)
|
||||||
|
}
|
||||||
|
if len(ml.Trackers) != tt.wantTrack {
|
||||||
|
t.Errorf("Parse() got %v trackers, want %v", len(ml.Trackers), tt.wantTrack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
115
internal/portforward/upnp.go
Normal file
115
internal/portforward/upnp.go
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
package portforward
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/huin/goupnp/dcps/internetgateway1"
|
||||||
|
"github.com/veggiedefender/torrent-client/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PortManager struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
clients []*internetgateway1.WANIPConnection1
|
||||||
|
port uint16
|
||||||
|
opened bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPortManager() *PortManager {
|
||||||
|
return &PortManager{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenPort attempts to open the specified port on all discovered UPnP routers.
|
||||||
|
func (pm *PortManager) OpenPort(ctx context.Context, port uint16, desc string) error {
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
|
||||||
|
if pm.opened && pm.port == port {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("SYS", "Discovering UPnP routers for port %d...", port)
|
||||||
|
|
||||||
|
discoverCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
discoverDone := make(chan []*internetgateway1.WANIPConnection1, 1)
|
||||||
|
go func() {
|
||||||
|
clients, _, _ := internetgateway1.NewWANIPConnection1Clients()
|
||||||
|
discoverDone <- clients
|
||||||
|
}()
|
||||||
|
|
||||||
|
var clients []*internetgateway1.WANIPConnection1
|
||||||
|
select {
|
||||||
|
case <-discoverCtx.Done():
|
||||||
|
logger.Warn("SYS", "UPnP discovery timed out")
|
||||||
|
return nil
|
||||||
|
case clients = <-discoverDone:
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(clients) == 0 {
|
||||||
|
logger.Warn("SYS", "No UPnP routers discovered")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
localIP, err := getLocalIP()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("SYS", "Could not get local IP for UPnP")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
success := false
|
||||||
|
for _, client := range clients {
|
||||||
|
// TCP Mapping
|
||||||
|
errTCP := client.AddPortMapping("", port, "TCP", port, localIP, true, desc, 0)
|
||||||
|
// UDP Mapping (for DHT)
|
||||||
|
errUDP := client.AddPortMapping("", port, "UDP", port, localIP, true, desc, 0)
|
||||||
|
|
||||||
|
if errTCP == nil || errUDP == nil {
|
||||||
|
success = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if success {
|
||||||
|
pm.clients = clients
|
||||||
|
pm.port = port
|
||||||
|
pm.opened = true
|
||||||
|
logger.Info("SYS", "UPnP successfully forwarded port %d", port)
|
||||||
|
} else {
|
||||||
|
logger.Warn("SYS", "Failed to add UPnP port mapping")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClosePort closes the currently opened port.
|
||||||
|
func (pm *PortManager) ClosePort() error {
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
|
||||||
|
if !pm.opened || len(pm.clients) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, client := range pm.clients {
|
||||||
|
_ = client.DeletePortMapping("", pm.port, "TCP")
|
||||||
|
_ = client.DeletePortMapping("", pm.port, "UDP")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("SYS", "UPnP successfully cleared port %d", pm.port)
|
||||||
|
pm.opened = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLocalIP returns the preferred outbound IP of this machine
|
||||||
|
func getLocalIP() (string, error) {
|
||||||
|
conn, err := net.Dial("udp", "8.8.8.8:80")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
localAddr := conn.LocalAddr().(*net.UDPAddr)
|
||||||
|
return localAddr.IP.String(), nil
|
||||||
|
}
|
||||||
67
internal/sorter/sorter.go
Normal file
67
internal/sorter/sorter.go
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
package sorter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/veggiedefender/torrent-client/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
tvShowRegex = regexp.MustCompile(`(?i)(s\d{2}e\d{2}|season\s*\d+|сезон\s*\d+)`)
|
||||||
|
movieRegex = regexp.MustCompile(`(?i)(1080p|720p|2160p|4k|bdrip|web-dl|bluray|camrip)`)
|
||||||
|
gameRegex = regexp.MustCompile(`(?i)(repack|fitgirl|dodi|skidrow|codex|crack|iso)`)
|
||||||
|
musicRegex = regexp.MustCompile(`(?i)(discography|flac|mp3\s*320)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Categorize analyzes the torrent name and determines its category.
|
||||||
|
func Categorize(name string) string {
|
||||||
|
if tvShowRegex.MatchString(name) {
|
||||||
|
return "TV Shows"
|
||||||
|
}
|
||||||
|
if movieRegex.MatchString(name) {
|
||||||
|
return "Movies"
|
||||||
|
}
|
||||||
|
if gameRegex.MatchString(name) {
|
||||||
|
return "Games"
|
||||||
|
}
|
||||||
|
if musicRegex.MatchString(name) {
|
||||||
|
return "Music"
|
||||||
|
}
|
||||||
|
return "Other"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortAndMove moves the downloaded files (or folder) to a sub-folder based on its category.
|
||||||
|
func SortAndMove(outputRoot, torrentName string) (string, error) {
|
||||||
|
category := Categorize(torrentName)
|
||||||
|
if category == "Other" || category == "" {
|
||||||
|
return "", nil // Do not move if category is unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
sourcePath := filepath.Join(outputRoot, torrentName)
|
||||||
|
if _, err := os.Stat(sourcePath); os.IsNotExist(err) {
|
||||||
|
return "", fmt.Errorf("source path does not exist: %s", sourcePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
destDir := filepath.Join(outputRoot, category)
|
||||||
|
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create category directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
destPath := filepath.Join(destDir, torrentName)
|
||||||
|
|
||||||
|
// If it already exists in the destination, maybe we resume/overwrite
|
||||||
|
if _, err := os.Stat(destPath); err == nil {
|
||||||
|
logger.Warn("SYS", "Destination path already exists, skipping move: %s", destPath)
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(sourcePath, destPath); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to move files to category folder: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("SYS", "Smart Sorter moved '%s' to '%s'", torrentName, category)
|
||||||
|
return destDir, nil
|
||||||
|
}
|
||||||
116
internal/storage/reader.go
Normal file
116
internal/storage/reader.go
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/veggiedefender/torrent-client/internal/torrentfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PieceReader reads pieces from the finalized torrent files on disk.
|
||||||
|
type PieceReader struct {
|
||||||
|
files []torrentfile.File
|
||||||
|
outputRoot string
|
||||||
|
pieceLength int
|
||||||
|
totalLength int
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
fdMap map[string]*os.File
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPieceReader creates a new PieceReader.
|
||||||
|
func NewPieceReader(files []torrentfile.File, pieceLength, totalLength int, outputRoot string) *PieceReader {
|
||||||
|
return &PieceReader{
|
||||||
|
files: files,
|
||||||
|
outputRoot: outputRoot,
|
||||||
|
pieceLength: pieceLength,
|
||||||
|
totalLength: totalLength,
|
||||||
|
fdMap: make(map[string]*os.File),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes all open file descriptors
|
||||||
|
func (pr *PieceReader) Close() {
|
||||||
|
pr.mu.Lock()
|
||||||
|
defer pr.mu.Unlock()
|
||||||
|
for _, f := range pr.fdMap {
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
pr.fdMap = make(map[string]*os.File)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pr *PieceReader) getFile(path string) (*os.File, error) {
|
||||||
|
pr.mu.Lock()
|
||||||
|
defer pr.mu.Unlock()
|
||||||
|
|
||||||
|
if f, ok := pr.fdMap[path]; ok {
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pr.fdMap[path] = f
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadBlock reads a specific block of data from the files.
|
||||||
|
func (pr *PieceReader) ReadBlock(pieceIndex, begin, length int) ([]byte, error) {
|
||||||
|
absoluteOffset := pieceIndex*pr.pieceLength + begin
|
||||||
|
if absoluteOffset+length > pr.totalLength {
|
||||||
|
return nil, errors.New("read block exceeds total length")
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, length)
|
||||||
|
bytesRead := 0
|
||||||
|
|
||||||
|
var currentOffset int
|
||||||
|
for _, file := range pr.files {
|
||||||
|
if currentOffset+file.Length <= absoluteOffset {
|
||||||
|
currentOffset += file.Length
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentOffset >= absoluteOffset+length {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
fileOffset := 0
|
||||||
|
if absoluteOffset > currentOffset {
|
||||||
|
fileOffset = absoluteOffset - currentOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
readLength := file.Length - fileOffset
|
||||||
|
if readLength > length-bytesRead {
|
||||||
|
readLength = length - bytesRead
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := filepath.Join(pr.outputRoot, file.Path)
|
||||||
|
f, err := pr.getFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := f.ReadAt(buf[bytesRead:bytesRead+readLength], int64(fileOffset))
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bytesRead += n
|
||||||
|
if n < readLength {
|
||||||
|
break // EOF reached prematurely
|
||||||
|
}
|
||||||
|
|
||||||
|
currentOffset += file.Length
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytesRead < length {
|
||||||
|
return nil, errors.New("could not read full block")
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
70
internal/storage/reader_test.go
Normal file
70
internal/storage/reader_test.go
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/veggiedefender/torrent-client/internal/torrentfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPieceReader(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
file1 := filepath.Join(tempDir, "file1.txt")
|
||||||
|
file2 := filepath.Join(tempDir, "file2.txt")
|
||||||
|
|
||||||
|
data1 := []byte("hello ") // 6 bytes
|
||||||
|
data2 := []byte("world!") // 6 bytes
|
||||||
|
|
||||||
|
if err := os.WriteFile(file1, data1, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(file2, data2, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files := []torrentfile.File{
|
||||||
|
{Path: "file1.txt", Length: 6},
|
||||||
|
{Path: "file2.txt", Length: 6},
|
||||||
|
}
|
||||||
|
|
||||||
|
pr := NewPieceReader(files, 4, 12, tempDir)
|
||||||
|
|
||||||
|
// Test reading from first file only
|
||||||
|
// Piece 0, begin 0, length 4 -> "hell"
|
||||||
|
b, err := pr.ReadBlock(0, 0, 4)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(b, []byte("hell")) {
|
||||||
|
t.Errorf("expected 'hell', got %q", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test reading across files
|
||||||
|
// Piece 1, begin 0, length 4 -> absolute offset 4 -> "o wo"
|
||||||
|
b, err = pr.ReadBlock(1, 0, 4)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(b, []byte("o wo")) {
|
||||||
|
t.Errorf("expected 'o wo', got %q", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test reading at the end
|
||||||
|
// Piece 2, begin 0, length 4 -> absolute offset 8 -> "rld!"
|
||||||
|
b, err = pr.ReadBlock(2, 0, 4)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(b, []byte("rld!")) {
|
||||||
|
t.Errorf("expected 'rld!', got %q", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test reading out of bounds
|
||||||
|
_, err = pr.ReadBlock(2, 2, 4)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected error when reading out of bounds")
|
||||||
|
}
|
||||||
|
}
|
||||||
165
internal/tgbot/bot.go
Normal file
165
internal/tgbot/bot.go
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
package tgbot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
|
"github.com/veggiedefender/torrent-client/internal/config"
|
||||||
|
"github.com/veggiedefender/torrent-client/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Controller interface {
|
||||||
|
DownloadMagnet(magnetURI string) error
|
||||||
|
// You can add more methods if you want to support downloading .torrent files or getting status.
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bot struct {
|
||||||
|
api *tgbotapi.BotAPI
|
||||||
|
controller Controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBot(ctrl Controller, token string) (*Bot, error) {
|
||||||
|
if token == "" {
|
||||||
|
// Bot is disabled if no token is provided.
|
||||||
|
logger.Info("TGBOT", "TG_BOT_TOKEN not set, bot integration is disabled")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
api, err := tgbotapi.NewBotAPI(token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create bot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("TGBOT", "Authorized on account %s", api.Self.UserName)
|
||||||
|
|
||||||
|
return &Bot{
|
||||||
|
api: api,
|
||||||
|
controller: ctrl,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) Start() {
|
||||||
|
if b == nil || b.api == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u := tgbotapi.NewUpdate(0)
|
||||||
|
u.Timeout = 60
|
||||||
|
|
||||||
|
updates := b.api.GetUpdatesChan(u)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
cfg, _ := config.Load()
|
||||||
|
var ownerID int64
|
||||||
|
if cfg != nil {
|
||||||
|
ownerID = cfg.TelegramOwnerID
|
||||||
|
}
|
||||||
|
|
||||||
|
for update := range updates {
|
||||||
|
if update.Message == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorization Check
|
||||||
|
if ownerID == 0 {
|
||||||
|
if update.Message.Text == "/start" {
|
||||||
|
ownerID = update.Message.From.ID
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = &config.Config{}
|
||||||
|
}
|
||||||
|
cfg.TelegramOwnerID = ownerID
|
||||||
|
_ = config.Save(cfg)
|
||||||
|
b.api.Send(tgbotapi.NewMessage(update.Message.Chat.ID, "You are now registered as the owner of this Ztorrent instance."))
|
||||||
|
} else {
|
||||||
|
b.api.Send(tgbotapi.NewMessage(update.Message.Chat.ID, "Ztorrent is waiting for the owner to send /start."))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
} else if update.Message.From.ID != ownerID {
|
||||||
|
logger.Warn("TGBOT", "Unauthorized access attempt from %s", update.Message.From.UserName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for documents (.torrent files)
|
||||||
|
if update.Message.Document != nil {
|
||||||
|
doc := update.Message.Document
|
||||||
|
if strings.HasSuffix(strings.ToLower(doc.FileName), ".torrent") {
|
||||||
|
logger.Info("TGBOT", "Received .torrent file from %s: %s", update.Message.From.UserName, doc.FileName)
|
||||||
|
|
||||||
|
// Get file URL from Telegram
|
||||||
|
fileURL, err := b.api.GetFileDirectURL(doc.FileID)
|
||||||
|
if err != nil {
|
||||||
|
b.api.Send(tgbotapi.NewMessage(update.Message.Chat.ID, fmt.Sprintf("Error getting file: %v", err)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
b.api.Send(tgbotapi.NewMessage(update.Message.Chat.ID, "Torrent file received, starting download..."))
|
||||||
|
|
||||||
|
// Download file contents async
|
||||||
|
go func(chatId int64, url, name string) {
|
||||||
|
err := b.downloadAndStartTorrent(chatId, url, name)
|
||||||
|
if err != nil {
|
||||||
|
b.api.Send(tgbotapi.NewMessage(chatId, fmt.Sprintf("Error starting torrent: %v", err)))
|
||||||
|
} else {
|
||||||
|
b.api.Send(tgbotapi.NewMessage(chatId, "Download started successfully!"))
|
||||||
|
}
|
||||||
|
}(update.Message.Chat.ID, fileURL, doc.FileName)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
text := strings.TrimSpace(update.Message.Text)
|
||||||
|
|
||||||
|
if strings.HasPrefix(text, "magnet:?") {
|
||||||
|
logger.Info("TGBOT", "Received magnet link from %s", update.Message.From.UserName)
|
||||||
|
msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Magnet link received, starting download...")
|
||||||
|
b.api.Send(msg)
|
||||||
|
|
||||||
|
err := b.controller.DownloadMagnet(text)
|
||||||
|
if err != nil {
|
||||||
|
errMsg := tgbotapi.NewMessage(update.Message.Chat.ID, fmt.Sprintf("Error starting download: %v", err))
|
||||||
|
b.api.Send(errMsg)
|
||||||
|
} else {
|
||||||
|
succMsg := tgbotapi.NewMessage(update.Message.Chat.ID, "Download started successfully!")
|
||||||
|
b.api.Send(succMsg)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if text == "/start" {
|
||||||
|
b.api.Send(tgbotapi.NewMessage(update.Message.Chat.ID, "Welcome back, Owner! Send me a magnet link or a .torrent file."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) downloadAndStartTorrent(chatID int64, fileURL, fileName string) error {
|
||||||
|
resp, err := http.Get(fileURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to download file from telegram: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("bad status code: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.CreateTemp("", "*_"+fileName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create temp file: %w", err)
|
||||||
|
}
|
||||||
|
tmpPath := out.Name()
|
||||||
|
|
||||||
|
_, err = io.Copy(out, resp.Body)
|
||||||
|
out.Close()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// We pass the local path to controller
|
||||||
|
return b.controller.DownloadMagnet(tmpPath)
|
||||||
|
}
|
||||||
17
internal/torrent/debug.go
Normal file
17
internal/torrent/debug.go
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
package torrent
|
||||||
|
|
||||||
|
import "github.com/veggiedefender/torrent-client/internal/logger"
|
||||||
|
|
||||||
|
// debug enables verbose protocol and scheduler logs.
|
||||||
|
var debug = true
|
||||||
|
|
||||||
|
func debugf(format string, args ...any) {
|
||||||
|
if !debug {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debug("ENGINE", format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetDebug(enabled bool) {
|
||||||
|
debug = enabled
|
||||||
|
}
|
||||||
2405
internal/torrent/engine.go
Normal file
2405
internal/torrent/engine.go
Normal file
File diff suppressed because it is too large
Load diff
993
internal/torrent/peerwire.go
Normal file
993
internal/torrent/peerwire.go
Normal file
|
|
@ -0,0 +1,993 @@
|
||||||
|
package torrent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackpal/bencode-go"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
|
||||||
|
"github.com/veggiedefender/torrent-client/internal/logger"
|
||||||
|
"github.com/veggiedefender/torrent-client/internal/torrentfile"
|
||||||
|
"github.com/veggiedefender/torrent-client/internal/tracker"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
wireProtocolString = "BitTorrent protocol"
|
||||||
|
|
||||||
|
msgChoke = 0
|
||||||
|
msgUnchoke = 1
|
||||||
|
msgInterested = 2
|
||||||
|
msgNotInterested = 3
|
||||||
|
msgHave = 4
|
||||||
|
msgBitfield = 5
|
||||||
|
msgRequest = 6
|
||||||
|
msgPiece = 7
|
||||||
|
msgCancel = 8
|
||||||
|
msgExtended = 20
|
||||||
|
|
||||||
|
maxWireMessageSize = 2 * 1024 * 1024
|
||||||
|
requestBlockSize = 16 * 1024
|
||||||
|
|
||||||
|
// Adaptive pipeline bounds
|
||||||
|
minPipelineDepth = 4
|
||||||
|
maxPipelineDepth = 64
|
||||||
|
initPipelineDepth = 8
|
||||||
|
|
||||||
|
keepaliveInterval = 90 * time.Second
|
||||||
|
|
||||||
|
peerConnectTimeout = 5 * time.Second
|
||||||
|
peerReadTimeout = 15 * time.Second
|
||||||
|
peerWriteTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
peerSocketReadBuffer = 512 * 1024
|
||||||
|
peerSocketWriteBuffer = 512 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
var wireMsgPool = sync.Pool{
|
||||||
|
New: func() any {
|
||||||
|
// typical piece msg: 4(len) + 1(id) + 8(header) + 16384(block) = 16397
|
||||||
|
b := make([]byte, requestBlockSize+128)
|
||||||
|
return &b
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type extendedHandshake struct {
|
||||||
|
M map[string]int `bencode:"m"`
|
||||||
|
MetadataSize int `bencode:"metadata_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type pexMessage struct {
|
||||||
|
Added string `bencode:"added,omitempty"`
|
||||||
|
Added6 string `bencode:"added6,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wireMessage struct {
|
||||||
|
ID int
|
||||||
|
Payload []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type pieceTransferStats struct {
|
||||||
|
DownloadedBytes int64
|
||||||
|
UploadedBytes int64
|
||||||
|
AvgBlockLatency time.Duration
|
||||||
|
Blocks int
|
||||||
|
Duration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type measuredConn struct {
|
||||||
|
net.Conn
|
||||||
|
readBytes atomic.Int64
|
||||||
|
writeBytes atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *measuredConn) Read(p []byte) (int, error) {
|
||||||
|
n, err := c.Conn.Read(p)
|
||||||
|
if n > 0 {
|
||||||
|
c.readBytes.Add(int64(n))
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *measuredConn) Write(p []byte) (int, error) {
|
||||||
|
n, err := c.Conn.Write(p)
|
||||||
|
if n > 0 {
|
||||||
|
c.writeBytes.Add(int64(n))
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *measuredConn) Snapshot() (readBytes int64, writeBytes int64) {
|
||||||
|
return c.readBytes.Load(), c.writeBytes.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// rateLimitedConn оборачивает measuredConn и применяет token-bucket rate limiting.
|
||||||
|
type rateLimitedConn struct {
|
||||||
|
*measuredConn
|
||||||
|
downLimiter *rate.Limiter
|
||||||
|
upLimiter *rate.Limiter
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRateLimitedConn(inner *measuredConn) *rateLimitedConn {
|
||||||
|
return &rateLimitedConn{
|
||||||
|
measuredConn: inner,
|
||||||
|
downLimiter: rate.NewLimiter(rate.Inf, 0),
|
||||||
|
upLimiter: rate.NewLimiter(rate.Inf, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *rateLimitedConn) SetDownloadLimit(bps int64) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if bps <= 0 {
|
||||||
|
c.downLimiter.SetLimit(rate.Inf)
|
||||||
|
c.downLimiter.SetBurst(0)
|
||||||
|
} else {
|
||||||
|
c.downLimiter.SetLimit(rate.Limit(bps))
|
||||||
|
c.downLimiter.SetBurst(int(bps))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *rateLimitedConn) SetUploadLimit(bps int64) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if bps <= 0 {
|
||||||
|
c.upLimiter.SetLimit(rate.Inf)
|
||||||
|
c.upLimiter.SetBurst(0)
|
||||||
|
} else {
|
||||||
|
c.upLimiter.SetLimit(rate.Limit(bps))
|
||||||
|
c.upLimiter.SetBurst(int(bps))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *rateLimitedConn) Read(p []byte) (int, error) {
|
||||||
|
n, err := c.measuredConn.Read(p)
|
||||||
|
if n > 0 {
|
||||||
|
c.mu.RLock()
|
||||||
|
lim := c.downLimiter
|
||||||
|
c.mu.RUnlock()
|
||||||
|
if lim.Limit() != rate.Inf {
|
||||||
|
_ = lim.WaitN(context.Background(), min(n, lim.Burst()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *rateLimitedConn) Write(p []byte) (int, error) {
|
||||||
|
if len(p) > 0 {
|
||||||
|
c.mu.RLock()
|
||||||
|
lim := c.upLimiter
|
||||||
|
c.mu.RUnlock()
|
||||||
|
if lim.Limit() != rate.Inf {
|
||||||
|
_ = lim.WaitN(context.Background(), min(len(p), lim.Burst()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.measuredConn.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
type peerClient struct {
|
||||||
|
conn *measuredConn
|
||||||
|
rlConn *rateLimitedConn
|
||||||
|
|
||||||
|
have []bool
|
||||||
|
hasPieceInfo bool
|
||||||
|
peerIsChoked bool
|
||||||
|
|
||||||
|
supportsExtensions bool
|
||||||
|
peerUtMetadataID int
|
||||||
|
metadataSize int
|
||||||
|
peerUtPexID int
|
||||||
|
OnPex func([]tracker.Peer)
|
||||||
|
|
||||||
|
// keepalive
|
||||||
|
kaStop chan struct{}
|
||||||
|
kaOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPeerClient(ctx context.Context, addr string, infoHash [20]byte, peerID [20]byte, pieceCount int) (*peerClient, error) {
|
||||||
|
dialer := net.Dialer{Timeout: peerConnectTimeout}
|
||||||
|
rawConn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
logger.Info("PEER", "peer dial failed %s: %v", addr, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
logger.Info("PEER", "connected to peer %s", addr)
|
||||||
|
|
||||||
|
if tcpConn, ok := rawConn.(*net.TCPConn); ok {
|
||||||
|
_ = tcpConn.SetNoDelay(true)
|
||||||
|
_ = tcpConn.SetReadBuffer(peerSocketReadBuffer)
|
||||||
|
_ = tcpConn.SetWriteBuffer(peerSocketWriteBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
measured := &measuredConn{Conn: rawConn}
|
||||||
|
rlConn := newRateLimitedConn(measured)
|
||||||
|
pc := &peerClient{
|
||||||
|
conn: measured,
|
||||||
|
rlConn: rlConn,
|
||||||
|
have: make([]bool, pieceCount),
|
||||||
|
peerIsChoked: true,
|
||||||
|
kaStop: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pc.sendHandshake(ctx, infoHash, peerID); err != nil {
|
||||||
|
measured.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := pc.readHandshake(ctx, infoHash); err != nil {
|
||||||
|
measured.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if pc.supportsExtensions {
|
||||||
|
if err := pc.sendExtendedHandshake(ctx); err != nil {
|
||||||
|
logger.Info("PEER", "failed to send extended handshake to %s: %v", addr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pc.sendMessage(ctx, msgInterested, nil); err != nil {
|
||||||
|
measured.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
debugf("sent interested to %s", addr)
|
||||||
|
|
||||||
|
if err := pc.readInitialMessages(ctx); err != nil {
|
||||||
|
logger.Info("PEER", "peer %s initial message read failed: %v", addr, err)
|
||||||
|
measured.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
go pc.keepaliveLoop()
|
||||||
|
return pc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *peerClient) PeerUtPexID() int {
|
||||||
|
return pc.peerUtPexID
|
||||||
|
}
|
||||||
|
|
||||||
|
func newIncomingPeerClient(ctx context.Context, rawConn net.Conn, extensions bool, pieceCount int) (*peerClient, error) {
|
||||||
|
measured := &measuredConn{Conn: rawConn}
|
||||||
|
rlConn := newRateLimitedConn(measured)
|
||||||
|
pc := &peerClient{
|
||||||
|
conn: measured,
|
||||||
|
rlConn: rlConn,
|
||||||
|
have: make([]bool, pieceCount),
|
||||||
|
peerIsChoked: true,
|
||||||
|
kaStop: make(chan struct{}),
|
||||||
|
supportsExtensions: extensions,
|
||||||
|
}
|
||||||
|
|
||||||
|
if pc.supportsExtensions {
|
||||||
|
if err := pc.sendExtendedHandshake(ctx); err != nil {
|
||||||
|
logger.Info("PEER", "failed to send extended handshake to incoming peer: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pc.sendMessage(ctx, msgInterested, nil); err != nil {
|
||||||
|
measured.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pc.readInitialMessages(ctx); err != nil {
|
||||||
|
measured.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
go pc.keepaliveLoop()
|
||||||
|
return pc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *peerClient) Close() error {
|
||||||
|
pc.kaOnce.Do(func() { close(pc.kaStop) })
|
||||||
|
return pc.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepaliveLoop отправляет keepalive (каждые 90с) пока соединение активно.
|
||||||
|
func (pc *peerClient) keepaliveLoop() {
|
||||||
|
ticker := time.NewTicker(keepaliveInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-pc.kaStop:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
// keepalive: send length-prefix 0 (no message ID)
|
||||||
|
_ = pc.conn.SetWriteDeadline(time.Now().Add(peerWriteTimeout))
|
||||||
|
_, _ = pc.conn.Write([]byte{0, 0, 0, 0})
|
||||||
|
debugf("sent keepalive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *peerClient) PieceAvailability() ([]bool, bool) {
|
||||||
|
have := make([]bool, len(pc.have))
|
||||||
|
copy(have, pc.have)
|
||||||
|
return have, pc.hasPieceInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrPieceCanceled = errors.New("piece download canceled")
|
||||||
|
|
||||||
|
func (pc *peerClient) DownloadPiece(ctx context.Context, pieceIndex int, pieceLength int, cancelCh <-chan struct{}) ([]byte, pieceTransferStats, error) {
|
||||||
|
var transfer pieceTransferStats
|
||||||
|
if pieceLength <= 0 {
|
||||||
|
return nil, transfer, fmt.Errorf("invalid piece length %d", pieceLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pc.waitForUnchoke(ctx); err != nil {
|
||||||
|
return nil, transfer, err
|
||||||
|
}
|
||||||
|
|
||||||
|
startReadBytes, startWriteBytes := pc.conn.Snapshot()
|
||||||
|
startedAt := time.Now()
|
||||||
|
|
||||||
|
piece := make([]byte, pieceLength)
|
||||||
|
type pendingRequest struct {
|
||||||
|
length int
|
||||||
|
requestedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
pending := make(map[int]pendingRequest, maxPipelineDepth)
|
||||||
|
offset := 0
|
||||||
|
received := 0
|
||||||
|
var latencySum time.Duration
|
||||||
|
blocksCompleted := 0
|
||||||
|
depth := initPipelineDepth // adaptive, пересчитывается каждые 4 блока
|
||||||
|
|
||||||
|
for received < pieceLength {
|
||||||
|
if cancelCh != nil {
|
||||||
|
select {
|
||||||
|
case <-cancelCh:
|
||||||
|
for begin, req := range pending {
|
||||||
|
pc.SendCancel(pieceIndex, begin, req.length)
|
||||||
|
}
|
||||||
|
return nil, transfer, ErrPieceCanceled
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for len(pending) < depth && offset < pieceLength {
|
||||||
|
blockLength := requestBlockSize
|
||||||
|
if remaining := pieceLength - offset; remaining < blockLength {
|
||||||
|
blockLength = remaining
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pc.sendRequest(ctx, pieceIndex, offset, blockLength); err != nil {
|
||||||
|
return nil, transfer, err
|
||||||
|
}
|
||||||
|
pending[offset] = pendingRequest{
|
||||||
|
length: blockLength,
|
||||||
|
requestedAt: time.Now(),
|
||||||
|
}
|
||||||
|
offset += blockLength
|
||||||
|
}
|
||||||
|
|
||||||
|
gotIndex, gotBegin, block, bufPtr, err := pc.readPieceMessage(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, transfer, err
|
||||||
|
}
|
||||||
|
if gotIndex != pieceIndex {
|
||||||
|
if bufPtr != nil {
|
||||||
|
wireMsgPool.Put(bufPtr)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
req, ok := pending[gotBegin]
|
||||||
|
if !ok {
|
||||||
|
if bufPtr != nil {
|
||||||
|
wireMsgPool.Put(bufPtr)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(block) != req.length {
|
||||||
|
if bufPtr != nil {
|
||||||
|
wireMsgPool.Put(bufPtr)
|
||||||
|
}
|
||||||
|
return nil, transfer, fmt.Errorf("unexpected block size for piece=%d begin=%d: got=%d want=%d", pieceIndex, gotBegin, len(block), req.length)
|
||||||
|
}
|
||||||
|
if gotBegin < 0 || gotBegin+len(block) > len(piece) {
|
||||||
|
if bufPtr != nil {
|
||||||
|
wireMsgPool.Put(bufPtr)
|
||||||
|
}
|
||||||
|
return nil, transfer, fmt.Errorf("piece block bounds invalid for piece=%d begin=%d block=%d", pieceIndex, gotBegin, len(block))
|
||||||
|
}
|
||||||
|
|
||||||
|
copy(piece[gotBegin:gotBegin+len(block)], block)
|
||||||
|
delete(pending, gotBegin)
|
||||||
|
received += len(block)
|
||||||
|
|
||||||
|
if bufPtr != nil {
|
||||||
|
wireMsgPool.Put(bufPtr)
|
||||||
|
}
|
||||||
|
|
||||||
|
blocksCompleted++
|
||||||
|
blockLatency := time.Since(req.requestedAt)
|
||||||
|
latencySum += blockLatency
|
||||||
|
|
||||||
|
// Пересчёт adaptive depth каждые 4 блока
|
||||||
|
if blocksCompleted%4 == 0 && blockLatency > 0 {
|
||||||
|
elapsedSec := blockLatency.Seconds()
|
||||||
|
curReadBytes, _ := pc.conn.Snapshot()
|
||||||
|
bytesSoFar := curReadBytes - startReadBytes
|
||||||
|
if elapsedSec > 0 && bytesSoFar > 0 && time.Since(startedAt).Seconds() > 0 {
|
||||||
|
bwBps := float64(bytesSoFar) / time.Since(startedAt).Seconds()
|
||||||
|
newDepth := int(bwBps * elapsedSec / float64(requestBlockSize))
|
||||||
|
if newDepth < minPipelineDepth {
|
||||||
|
newDepth = minPipelineDepth
|
||||||
|
}
|
||||||
|
if newDepth > maxPipelineDepth {
|
||||||
|
newDepth = maxPipelineDepth
|
||||||
|
}
|
||||||
|
depth = newDepth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
endReadBytes, endWriteBytes := pc.conn.Snapshot()
|
||||||
|
transfer.DownloadedBytes = maxInt64(0, endReadBytes-startReadBytes)
|
||||||
|
transfer.UploadedBytes = maxInt64(0, endWriteBytes-startWriteBytes)
|
||||||
|
transfer.Blocks = blocksCompleted
|
||||||
|
transfer.Duration = time.Since(startedAt)
|
||||||
|
if blocksCompleted > 0 {
|
||||||
|
transfer.AvgBlockLatency = latencySum / time.Duration(blocksCompleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
return piece, transfer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendCancel отправляет сообщение cancel пиру (используется в endgame-режиме).
|
||||||
|
func (pc *peerClient) SendCancel(pieceIndex, begin, length int) {
|
||||||
|
payload := make([]byte, 12)
|
||||||
|
binary.BigEndian.PutUint32(payload[0:4], uint32(pieceIndex))
|
||||||
|
binary.BigEndian.PutUint32(payload[4:8], uint32(begin))
|
||||||
|
binary.BigEndian.PutUint32(payload[8:12], uint32(length))
|
||||||
|
_ = pc.conn.SetWriteDeadline(time.Now().Add(peerWriteTimeout))
|
||||||
|
_ = pc.sendMessageDirect(msgCancel, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMessageDirect — упрощенная версия sendMessage без ctx (deadline уже выставлен).
|
||||||
|
func (pc *peerClient) sendMessageDirect(msgID int, payload []byte) error {
|
||||||
|
length := uint32(1 + len(payload))
|
||||||
|
buf := make([]byte, 4+length)
|
||||||
|
binary.BigEndian.PutUint32(buf[0:4], length)
|
||||||
|
buf[4] = byte(msgID)
|
||||||
|
copy(buf[5:], payload)
|
||||||
|
_, err := pc.conn.Write(buf)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *peerClient) readInitialMessages(ctx context.Context) error {
|
||||||
|
if err := pc.setReadDeadlineFromContext(ctx, 2*time.Second); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer pc.conn.SetReadDeadline(time.Time{})
|
||||||
|
|
||||||
|
for {
|
||||||
|
msg, bufPtr, err := readWireMessage(pc.conn)
|
||||||
|
if err != nil {
|
||||||
|
if isTimeout(err) {
|
||||||
|
debugf("peer initial message window finished")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pc.consumeMessage(msg)
|
||||||
|
if bufPtr != nil {
|
||||||
|
wireMsgPool.Put(bufPtr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *peerClient) waitForUnchoke(ctx context.Context) error {
|
||||||
|
if !pc.peerIsChoked {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(12 * time.Second)
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return errors.New("peer did not unchoke")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pc.setReadDeadlineFromContext(ctx, peerReadTimeout); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
msg, bufPtr, err := readWireMessage(pc.conn)
|
||||||
|
if err != nil {
|
||||||
|
if isTimeout(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pc.consumeMessage(msg)
|
||||||
|
msgID := msg.ID
|
||||||
|
if bufPtr != nil {
|
||||||
|
wireMsgPool.Put(bufPtr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if msgID == msgUnchoke {
|
||||||
|
debugf("peer unchoked")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *peerClient) sendRequest(ctx context.Context, pieceIndex, begin, length int) error {
|
||||||
|
debugf("request piece %d offset %d length %d", pieceIndex, begin, length)
|
||||||
|
payload := make([]byte, 12)
|
||||||
|
binary.BigEndian.PutUint32(payload[0:4], uint32(pieceIndex))
|
||||||
|
binary.BigEndian.PutUint32(payload[4:8], uint32(begin))
|
||||||
|
binary.BigEndian.PutUint32(payload[8:12], uint32(length))
|
||||||
|
return pc.sendMessage(ctx, msgRequest, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *peerClient) readPieceMessage(ctx context.Context) (pieceIndex int, begin int, block []byte, bufPtr *[]byte, err error) {
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return 0, 0, nil, nil, err
|
||||||
|
}
|
||||||
|
if err := pc.setReadDeadlineFromContext(ctx, peerReadTimeout); err != nil {
|
||||||
|
return 0, 0, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, ptr, err := readWireMessage(pc.conn)
|
||||||
|
if err != nil {
|
||||||
|
if isTimeout(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return 0, 0, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg.ID {
|
||||||
|
case msgPiece:
|
||||||
|
if len(msg.Payload) < 8 {
|
||||||
|
if ptr != nil {
|
||||||
|
wireMsgPool.Put(ptr)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
gotIndex := int(binary.BigEndian.Uint32(msg.Payload[0:4]))
|
||||||
|
gotBegin := int(binary.BigEndian.Uint32(msg.Payload[4:8]))
|
||||||
|
data := msg.Payload[8:]
|
||||||
|
if len(data) == 0 {
|
||||||
|
if ptr != nil {
|
||||||
|
wireMsgPool.Put(ptr)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
debugf("received piece %d offset %d block=%d", gotIndex, gotBegin, len(data))
|
||||||
|
return gotIndex, gotBegin, data, ptr, nil
|
||||||
|
case msgChoke:
|
||||||
|
pc.consumeMessage(msg)
|
||||||
|
if ptr != nil {
|
||||||
|
wireMsgPool.Put(ptr)
|
||||||
|
}
|
||||||
|
return 0, 0, nil, nil, errors.New("peer choked")
|
||||||
|
default:
|
||||||
|
pc.consumeMessage(msg)
|
||||||
|
if ptr != nil {
|
||||||
|
wireMsgPool.Put(ptr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *peerClient) consumeMessage(msg wireMessage) {
|
||||||
|
switch msg.ID {
|
||||||
|
case msgChoke:
|
||||||
|
pc.peerIsChoked = true
|
||||||
|
debugf("peer sent choke")
|
||||||
|
case msgUnchoke:
|
||||||
|
pc.peerIsChoked = false
|
||||||
|
debugf("peer sent unchoke")
|
||||||
|
case msgHave:
|
||||||
|
if len(msg.Payload) < 4 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idx := int(binary.BigEndian.Uint32(msg.Payload[:4]))
|
||||||
|
if idx >= 0 && idx < len(pc.have) {
|
||||||
|
pc.have[idx] = true
|
||||||
|
pc.hasPieceInfo = true
|
||||||
|
debugf("received have piece %d", idx)
|
||||||
|
}
|
||||||
|
case msgBitfield:
|
||||||
|
pc.hasPieceInfo = true
|
||||||
|
debugf("received bitfield (%d bytes)", len(msg.Payload))
|
||||||
|
for i := range pc.have {
|
||||||
|
pc.have[i] = bitfieldHasPiece(msg.Payload, i)
|
||||||
|
}
|
||||||
|
case msgExtended:
|
||||||
|
if len(msg.Payload) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
extID := int(msg.Payload[0])
|
||||||
|
if extID == 0 {
|
||||||
|
var extMsg extendedHandshake
|
||||||
|
if err := bencode.Unmarshal(bytes.NewReader(msg.Payload[1:]), &extMsg); err == nil {
|
||||||
|
if id, ok := extMsg.M["ut_metadata"]; ok {
|
||||||
|
pc.peerUtMetadataID = id
|
||||||
|
}
|
||||||
|
if id, ok := extMsg.M["ut_pex"]; ok {
|
||||||
|
pc.peerUtPexID = id
|
||||||
|
}
|
||||||
|
if extMsg.MetadataSize > 0 {
|
||||||
|
pc.metadataSize = extMsg.MetadataSize
|
||||||
|
}
|
||||||
|
debugf("received extended handshake, ut_metadata=%d, ut_pex=%d, size=%d", pc.peerUtMetadataID, pc.peerUtPexID, pc.metadataSize)
|
||||||
|
}
|
||||||
|
} else if extID == pc.peerUtPexID && pc.peerUtPexID != 0 {
|
||||||
|
if pc.OnPex != nil {
|
||||||
|
if peers := ParsePexPayload(msg.Payload[1:]); len(peers) > 0 {
|
||||||
|
pc.OnPex(peers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *peerClient) sendExtendedHandshake(ctx context.Context) error {
|
||||||
|
debugf("sending extended handshake")
|
||||||
|
msg := extendedHandshake{
|
||||||
|
M: map[string]int{
|
||||||
|
"ut_metadata": 1,
|
||||||
|
"ut_pex": 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
buf.WriteByte(0) // Extended message ID 0 for handshake
|
||||||
|
if err := bencode.Marshal(&buf, msg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return pc.sendMessage(ctx, msgExtended, buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *peerClient) sendHandshake(ctx context.Context, infoHash [20]byte, peerID [20]byte) error {
|
||||||
|
debugf("sending handshake")
|
||||||
|
payload := make([]byte, 49+len(wireProtocolString))
|
||||||
|
payload[0] = byte(len(wireProtocolString))
|
||||||
|
copy(payload[1:1+len(wireProtocolString)], wireProtocolString)
|
||||||
|
payload[25] |= 0x10 // Set extension protocol bit
|
||||||
|
copy(payload[1+len(wireProtocolString)+8:1+len(wireProtocolString)+8+20], infoHash[:])
|
||||||
|
copy(payload[1+len(wireProtocolString)+8+20:], peerID[:])
|
||||||
|
|
||||||
|
if err := pc.setWriteDeadlineFromContext(ctx, peerWriteTimeout); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := pc.conn.Write(payload)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *peerClient) readHandshake(ctx context.Context, expectedInfoHash [20]byte) error {
|
||||||
|
head := make([]byte, 1)
|
||||||
|
if err := pc.setReadDeadlineFromContext(ctx, peerReadTimeout); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.ReadFull(pc.conn, head); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
pstrlen := int(head[0])
|
||||||
|
if pstrlen <= 0 || pstrlen > 64 {
|
||||||
|
return fmt.Errorf("invalid handshake pstrlen %d", pstrlen)
|
||||||
|
}
|
||||||
|
|
||||||
|
rest := make([]byte, pstrlen+48)
|
||||||
|
if _, err := io.ReadFull(pc.conn, rest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(rest[:pstrlen]) != wireProtocolString {
|
||||||
|
return errors.New("invalid peer protocol string")
|
||||||
|
}
|
||||||
|
|
||||||
|
infoHashOffset := pstrlen + 8
|
||||||
|
if !bytes.Equal(rest[infoHashOffset:infoHashOffset+20], expectedInfoHash[:]) {
|
||||||
|
return errors.New("peer info_hash mismatch")
|
||||||
|
}
|
||||||
|
pc.supportsExtensions = (rest[24] & 0x10) != 0
|
||||||
|
debugf("handshake complete (extensions: %v)", pc.supportsExtensions)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *peerClient) SendUnchoke(ctx context.Context) error {
|
||||||
|
return pc.sendMessage(ctx, msgUnchoke, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *peerClient) SendChoke(ctx context.Context) error {
|
||||||
|
return pc.sendMessage(ctx, msgChoke, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *peerClient) SendBitfield(ctx context.Context, bitfield []byte) error {
|
||||||
|
return pc.sendMessage(ctx, msgBitfield, bitfield)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *peerClient) SendPiece(ctx context.Context, index, begin int, data []byte) error {
|
||||||
|
payload := make([]byte, 8+len(data))
|
||||||
|
binary.BigEndian.PutUint32(payload[0:4], uint32(index))
|
||||||
|
binary.BigEndian.PutUint32(payload[4:8], uint32(begin))
|
||||||
|
copy(payload[8:], data)
|
||||||
|
return pc.sendMessage(ctx, msgPiece, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParsePexPayload(payload []byte) []tracker.Peer {
|
||||||
|
var msg pexMessage
|
||||||
|
if err := bencode.Unmarshal(bytes.NewReader(payload), &msg); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var allPeers []tracker.Peer
|
||||||
|
if len(msg.Added) > 0 {
|
||||||
|
if peers, err := tracker.ParsePeers([]byte(msg.Added)); err == nil {
|
||||||
|
allPeers = append(allPeers, peers...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(msg.Added6) > 0 {
|
||||||
|
if peers6, err := tracker.ParsePeers6([]byte(msg.Added6)); err == nil {
|
||||||
|
allPeers = append(allPeers, peers6...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allPeers
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *peerClient) SendPex(ctx context.Context, added []tracker.Peer) error {
|
||||||
|
if !pc.supportsExtensions || pc.peerUtPexID == 0 {
|
||||||
|
return errors.New("peer does not support ut_pex")
|
||||||
|
}
|
||||||
|
|
||||||
|
var addedBuf bytes.Buffer
|
||||||
|
for _, p := range added {
|
||||||
|
if v4 := p.IP.To4(); v4 != nil {
|
||||||
|
addedBuf.Write(v4)
|
||||||
|
var portBuf [2]byte
|
||||||
|
binary.BigEndian.PutUint16(portBuf[:], p.Port)
|
||||||
|
addedBuf.Write(portBuf[:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := pexMessage{
|
||||||
|
Added: addedBuf.String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
buf.WriteByte(byte(pc.peerUtPexID))
|
||||||
|
if err := bencode.Marshal(&buf, msg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return pc.sendMessage(ctx, msgExtended, buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *peerClient) ReadMessage(ctx context.Context) (wireMessage, *[]byte, error) {
|
||||||
|
if err := pc.setReadDeadlineFromContext(ctx, peerReadTimeout); err != nil {
|
||||||
|
return wireMessage{}, nil, err
|
||||||
|
}
|
||||||
|
return readWireMessage(pc.conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *peerClient) sendMessage(ctx context.Context, msgID int, payload []byte) error {
|
||||||
|
if err := pc.setWriteDeadlineFromContext(ctx, peerWriteTimeout); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
length := uint32(1 + len(payload))
|
||||||
|
|
||||||
|
// Get buffer from pool
|
||||||
|
bufPtr := wireMsgPool.Get().(*[]byte)
|
||||||
|
buf := *bufPtr
|
||||||
|
if uint32(len(buf)) < 4+length {
|
||||||
|
// Fallback to allocation if payload is larger than typical
|
||||||
|
buf = make([]byte, 4+length)
|
||||||
|
}
|
||||||
|
|
||||||
|
binary.BigEndian.PutUint32(buf[0:4], length)
|
||||||
|
buf[4] = byte(msgID)
|
||||||
|
copy(buf[5:], payload)
|
||||||
|
|
||||||
|
_, err := pc.conn.Write(buf[:4+length])
|
||||||
|
|
||||||
|
// Return buffer to pool
|
||||||
|
wireMsgPool.Put(bufPtr)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func readWireMessage(r io.Reader) (wireMessage, *[]byte, error) {
|
||||||
|
var lengthBuf [4]byte
|
||||||
|
if _, err := io.ReadFull(r, lengthBuf[:]); err != nil {
|
||||||
|
return wireMessage{}, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
length := binary.BigEndian.Uint32(lengthBuf[:])
|
||||||
|
if length == 0 {
|
||||||
|
return wireMessage{ID: -1}, nil, nil
|
||||||
|
}
|
||||||
|
if length > maxWireMessageSize {
|
||||||
|
return wireMessage{}, nil, fmt.Errorf("wire message too large: %d", length)
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg []byte
|
||||||
|
var bufPtr *[]byte
|
||||||
|
|
||||||
|
if length <= requestBlockSize+128 {
|
||||||
|
bufPtr = wireMsgPool.Get().(*[]byte)
|
||||||
|
msg = (*bufPtr)[:length]
|
||||||
|
} else {
|
||||||
|
msg = make([]byte, length)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.ReadFull(r, msg); err != nil {
|
||||||
|
if bufPtr != nil {
|
||||||
|
wireMsgPool.Put(bufPtr)
|
||||||
|
}
|
||||||
|
return wireMessage{}, nil, err
|
||||||
|
}
|
||||||
|
return wireMessage{ID: int(msg[0]), Payload: msg[1:]}, bufPtr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bitfieldHasPiece(bitfield []byte, index int) bool {
|
||||||
|
byteIndex := index / 8
|
||||||
|
if byteIndex < 0 || byteIndex >= len(bitfield) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
bitOffset := 7 - (index % 8)
|
||||||
|
return bitfield[byteIndex]&(1<<bitOffset) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func pieceSizeForIndex(tf *torrentfile.TorrentFile, pieceIndex int) int {
|
||||||
|
if pieceIndex < 0 || pieceIndex >= len(tf.PieceHashes) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if pieceIndex == len(tf.PieceHashes)-1 {
|
||||||
|
lastPieceSize := tf.Length % tf.PieceLength
|
||||||
|
if lastPieceSize == 0 {
|
||||||
|
return tf.PieceLength
|
||||||
|
}
|
||||||
|
return lastPieceSize
|
||||||
|
}
|
||||||
|
return tf.PieceLength
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *peerClient) PeerUtMetadataID() int {
|
||||||
|
return pc.peerUtMetadataID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *peerClient) MetadataSize() int {
|
||||||
|
return pc.metadataSize
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *peerClient) SendMetadataRequest(ctx context.Context, piece int) error {
|
||||||
|
if pc.peerUtMetadataID == 0 {
|
||||||
|
return errors.New("peer does not support ut_metadata")
|
||||||
|
}
|
||||||
|
msg := map[string]int{
|
||||||
|
"msg_type": 0, // request
|
||||||
|
"piece": piece,
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
buf.WriteByte(byte(pc.peerUtMetadataID))
|
||||||
|
if err := bencode.Marshal(&buf, msg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return pc.sendMessage(ctx, msgExtended, buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *peerClient) ReadMetadataMessage(ctx context.Context) (piece int, data []byte, reject bool, err error) {
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return 0, nil, false, err
|
||||||
|
}
|
||||||
|
if err := pc.setReadDeadlineFromContext(ctx, peerReadTimeout); err != nil {
|
||||||
|
return 0, nil, false, err
|
||||||
|
}
|
||||||
|
msg, ptr, err := readWireMessage(pc.conn)
|
||||||
|
if err != nil {
|
||||||
|
if isTimeout(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return 0, nil, false, err
|
||||||
|
}
|
||||||
|
if msg.ID == msgExtended {
|
||||||
|
if len(msg.Payload) == 0 {
|
||||||
|
if ptr != nil {
|
||||||
|
wireMsgPool.Put(ptr)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
extID := int(msg.Payload[0])
|
||||||
|
if extID == 1 { // our ut_metadata ID is 1
|
||||||
|
reader := bytes.NewReader(msg.Payload[1:])
|
||||||
|
var dict map[string]int
|
||||||
|
if err := bencode.Unmarshal(reader, &dict); err != nil {
|
||||||
|
if ptr != nil {
|
||||||
|
wireMsgPool.Put(ptr)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msgType, ok := dict["msg_type"]
|
||||||
|
if !ok {
|
||||||
|
if ptr != nil {
|
||||||
|
wireMsgPool.Put(ptr)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pieceIdx := dict["piece"]
|
||||||
|
|
||||||
|
if msgType == 2 {
|
||||||
|
if ptr != nil {
|
||||||
|
wireMsgPool.Put(ptr)
|
||||||
|
}
|
||||||
|
return pieceIdx, nil, true, nil
|
||||||
|
}
|
||||||
|
if msgType == 1 {
|
||||||
|
bytesRead := len(msg.Payload[1:]) - reader.Len()
|
||||||
|
srcData := msg.Payload[1+bytesRead:]
|
||||||
|
data := make([]byte, len(srcData))
|
||||||
|
copy(data, srcData)
|
||||||
|
if ptr != nil {
|
||||||
|
wireMsgPool.Put(ptr)
|
||||||
|
}
|
||||||
|
return pieceIdx, data, false, nil
|
||||||
|
}
|
||||||
|
} else if extID == 0 {
|
||||||
|
pc.consumeMessage(msg)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pc.consumeMessage(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ptr != nil {
|
||||||
|
wireMsgPool.Put(ptr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *peerClient) setReadDeadlineFromContext(ctx context.Context, fallback time.Duration) error {
|
||||||
|
deadline := time.Now().Add(fallback)
|
||||||
|
if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
|
||||||
|
deadline = ctxDeadline
|
||||||
|
}
|
||||||
|
return pc.conn.SetReadDeadline(deadline)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *peerClient) setWriteDeadlineFromContext(ctx context.Context, fallback time.Duration) error {
|
||||||
|
deadline := time.Now().Add(fallback)
|
||||||
|
if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
|
||||||
|
deadline = ctxDeadline
|
||||||
|
}
|
||||||
|
return pc.conn.SetWriteDeadline(deadline)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTimeout(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var netErr net.Error
|
||||||
|
return errors.As(err, &netErr) && netErr.Timeout()
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxInt64(a, b int64) int64 {
|
||||||
|
if a >= b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
477
internal/torrent/piecescheduler.go
Normal file
477
internal/torrent/piecescheduler.go
Normal file
|
|
@ -0,0 +1,477 @@
|
||||||
|
package torrent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pieceTask struct {
|
||||||
|
Index int
|
||||||
|
}
|
||||||
|
|
||||||
|
type assignPieceRequest struct {
|
||||||
|
peerID string
|
||||||
|
have []bool
|
||||||
|
hasInfo bool
|
||||||
|
responseCh chan assignPieceResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
type assignPieceResponse struct {
|
||||||
|
task pieceTask
|
||||||
|
ok bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type reportPieceRequest struct {
|
||||||
|
pieceIndex int
|
||||||
|
success bool
|
||||||
|
responseCh chan bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// cancelPeerRequest отправляет сигнал конкретному пиру отменить кусок в endgame.
|
||||||
|
type cancelPeerRequest struct {
|
||||||
|
pieceIndex int
|
||||||
|
peerID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type pieceScheduler struct {
|
||||||
|
assignCh chan assignPieceRequest
|
||||||
|
reportCh chan reportPieceRequest
|
||||||
|
releasePeerCh chan string
|
||||||
|
progressCh chan int
|
||||||
|
doneCh chan struct{}
|
||||||
|
stopCh chan struct{}
|
||||||
|
stopOnce sync.Once
|
||||||
|
|
||||||
|
setSequentialCh chan bool
|
||||||
|
|
||||||
|
// cancelCh рассылает сигнал отмены конкретному воркеру в endgame-режиме.
|
||||||
|
// Ключ — peerID, значение — канал с индексом куска для отмены.
|
||||||
|
cancelMu sync.RWMutex
|
||||||
|
cancelSubs map[string]chan int
|
||||||
|
}
|
||||||
|
|
||||||
|
type pieceState uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
piecePending pieceState = iota
|
||||||
|
pieceInProgress // качается одним пиром
|
||||||
|
pieceDone
|
||||||
|
)
|
||||||
|
|
||||||
|
// endgameThreshold — количество оставшихся кусков, при котором включается endgame.
|
||||||
|
const endgameThreshold = 4
|
||||||
|
|
||||||
|
func newPieceScheduler(pieceCount int) *pieceScheduler {
|
||||||
|
return newPieceSchedulerWithResume(pieceCount, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPieceSchedulerWithResume(pieceCount int, completedIndices []int) *pieceScheduler {
|
||||||
|
ps := &pieceScheduler{
|
||||||
|
assignCh: make(chan assignPieceRequest, 128),
|
||||||
|
reportCh: make(chan reportPieceRequest, 128),
|
||||||
|
releasePeerCh: make(chan string, 128),
|
||||||
|
progressCh: make(chan int, 1),
|
||||||
|
doneCh: make(chan struct{}),
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
setSequentialCh: make(chan bool),
|
||||||
|
cancelSubs: make(map[string]chan int),
|
||||||
|
}
|
||||||
|
|
||||||
|
go ps.run(pieceCount, completedIndices)
|
||||||
|
return ps
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscribeCancel регистрирует канал для получения cancel-сигналов в endgame-режиме.
|
||||||
|
// Воркер должен вызвать это перед Acquire, и отписаться после завершения.
|
||||||
|
func (ps *pieceScheduler) SubscribeCancel(peerID string) <-chan int {
|
||||||
|
ch := make(chan int, 8)
|
||||||
|
ps.cancelMu.Lock()
|
||||||
|
ps.cancelSubs[peerID] = ch
|
||||||
|
ps.cancelMu.Unlock()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnsubscribeCancel отписывает воркера от cancel-сигналов.
|
||||||
|
func (ps *pieceScheduler) UnsubscribeCancel(peerID string) {
|
||||||
|
ps.cancelMu.Lock()
|
||||||
|
delete(ps.cancelSubs, peerID)
|
||||||
|
ps.cancelMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// broadcastCancel рассылает сигнал отмены куска всем воркерам кроме победителя.
|
||||||
|
func (ps *pieceScheduler) broadcastCancel(pieceIndex int, winnerPeerID string) {
|
||||||
|
ps.cancelMu.RLock()
|
||||||
|
defer ps.cancelMu.RUnlock()
|
||||||
|
for id, ch := range ps.cancelSubs {
|
||||||
|
if id == winnerPeerID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case ch <- pieceIndex:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *pieceScheduler) Acquire(ctx context.Context, peerID string, have []bool, hasInfo bool) (pieceTask, bool, error) {
|
||||||
|
responseCh := make(chan assignPieceResponse, 1)
|
||||||
|
req := assignPieceRequest{
|
||||||
|
peerID: peerID,
|
||||||
|
have: have,
|
||||||
|
hasInfo: hasInfo,
|
||||||
|
responseCh: responseCh,
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return pieceTask{}, false, ctx.Err()
|
||||||
|
case <-ps.doneCh:
|
||||||
|
return pieceTask{}, false, nil
|
||||||
|
case ps.assignCh <- req:
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return pieceTask{}, false, ctx.Err()
|
||||||
|
case <-ps.doneCh:
|
||||||
|
return pieceTask{}, false, nil
|
||||||
|
case response := <-responseCh:
|
||||||
|
return response.task, response.ok, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *pieceScheduler) Report(ctx context.Context, pieceIndex int, success bool) (bool, error) {
|
||||||
|
responseCh := make(chan bool, 1)
|
||||||
|
req := reportPieceRequest{
|
||||||
|
pieceIndex: pieceIndex,
|
||||||
|
success: success,
|
||||||
|
responseCh: responseCh,
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false, ctx.Err()
|
||||||
|
case <-ps.doneCh:
|
||||||
|
return false, nil
|
||||||
|
case ps.reportCh <- req:
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false, ctx.Err()
|
||||||
|
case <-ps.doneCh:
|
||||||
|
return false, nil
|
||||||
|
case accepted := <-responseCh:
|
||||||
|
return accepted, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *pieceScheduler) ReleasePeer(peerID string) {
|
||||||
|
if peerID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ps.doneCh:
|
||||||
|
return
|
||||||
|
case ps.releasePeerCh <- peerID:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *pieceScheduler) Progress() <-chan int {
|
||||||
|
return ps.progressCh
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *pieceScheduler) Done() <-chan struct{} {
|
||||||
|
return ps.doneCh
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *pieceScheduler) Stop() {
|
||||||
|
ps.stopOnce.Do(func() {
|
||||||
|
close(ps.stopCh)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *pieceScheduler) SetSequential(mode bool) {
|
||||||
|
select {
|
||||||
|
case ps.setSequentialCh <- mode:
|
||||||
|
case <-ps.stopCh:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *pieceScheduler) run(pieceCount int, completedIndices []int) {
|
||||||
|
states := make([]pieceState, pieceCount)
|
||||||
|
availability := make([]int, pieceCount)
|
||||||
|
peerAvailability := make(map[string][]bool, 256)
|
||||||
|
endgamePeers := make(map[int][]string)
|
||||||
|
completed := 0
|
||||||
|
sequentialMode := false
|
||||||
|
|
||||||
|
for _, idx := range completedIndices {
|
||||||
|
if idx >= 0 && idx < pieceCount {
|
||||||
|
states[idx] = pieceDone
|
||||||
|
completed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finish := func() {
|
||||||
|
close(ps.doneCh)
|
||||||
|
close(ps.progressCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pieceCount == 0 {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
if completed >= pieceCount {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := pieceCount - completed
|
||||||
|
endgame := remaining <= endgameThreshold
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ps.stopCh:
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
case peerID := <-ps.releasePeerCh:
|
||||||
|
releasePeerAvailability(peerAvailability, availability, peerID)
|
||||||
|
case req := <-ps.assignCh:
|
||||||
|
if req.peerID != "" && req.hasInfo {
|
||||||
|
updatePeerAvailability(peerAvailability, availability, req.peerID, req.have, pieceCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
pieceIndex := -1
|
||||||
|
if endgame {
|
||||||
|
pieceIndex = selectPieceEndgame(states, availability, req.have, req.hasInfo, endgamePeers, req.peerID)
|
||||||
|
} else {
|
||||||
|
if sequentialMode {
|
||||||
|
pieceIndex = selectPendingPieceSequential(states, req.have, req.hasInfo)
|
||||||
|
} else {
|
||||||
|
pieceIndex = selectPendingPieceRarest(states, availability, req.have, req.hasInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pieceIndex >= 0 {
|
||||||
|
if !endgame {
|
||||||
|
states[pieceIndex] = pieceInProgress
|
||||||
|
}
|
||||||
|
endgamePeers[pieceIndex] = append(endgamePeers[pieceIndex], req.peerID)
|
||||||
|
req.responseCh <- assignPieceResponse{
|
||||||
|
task: pieceTask{Index: pieceIndex},
|
||||||
|
ok: true,
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
req.responseCh <- assignPieceResponse{ok: false}
|
||||||
|
|
||||||
|
case req := <-ps.reportCh:
|
||||||
|
if req.pieceIndex < 0 || req.pieceIndex >= pieceCount {
|
||||||
|
req.responseCh <- false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.success {
|
||||||
|
if states[req.pieceIndex] == pieceDone {
|
||||||
|
req.responseCh <- false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
states[req.pieceIndex] = pieceDone
|
||||||
|
completed++
|
||||||
|
|
||||||
|
// Endgame: уведомить других пиров отменить этот кусок
|
||||||
|
if peers, ok := endgamePeers[req.pieceIndex]; ok && len(peers) > 1 {
|
||||||
|
go ps.broadcastCancel(req.pieceIndex, "")
|
||||||
|
}
|
||||||
|
delete(endgamePeers, req.pieceIndex)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case ps.progressCh <- req.pieceIndex:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if states[req.pieceIndex] != pieceDone {
|
||||||
|
states[req.pieceIndex] = piecePending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req.responseCh <- true
|
||||||
|
|
||||||
|
case mode := <-ps.setSequentialCh:
|
||||||
|
sequentialMode = mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectPieceEndgame выбирает кусок в endgame режиме:
|
||||||
|
// разрешает назначать куски в состоянии InProgress другим пирам.
|
||||||
|
func selectPieceEndgame(states []pieceState, availability []int, have []bool, hasInfo bool, endgamePeers map[int][]string, peerID string) int {
|
||||||
|
// Сначала пробуем найти pending кусок (обычный путь)
|
||||||
|
if idx := selectPendingPieceRarest(states, availability, have, hasInfo); idx >= 0 {
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endgame: ищем InProgress кусок, который у нас ещё не назначен этому пиру
|
||||||
|
bestPiece := -1
|
||||||
|
bestAvailability := 0
|
||||||
|
|
||||||
|
for pieceIndex, state := range states {
|
||||||
|
if state != pieceInProgress {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Проверяем, не назначен ли уже этому пиру
|
||||||
|
alreadyAssigned := false
|
||||||
|
for _, pid := range endgamePeers[pieceIndex] {
|
||||||
|
if pid == peerID {
|
||||||
|
alreadyAssigned = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if alreadyAssigned {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Проверяем наличие у пира
|
||||||
|
if hasInfo && (pieceIndex >= len(have) || !have[pieceIndex]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
count := availability[pieceIndex]
|
||||||
|
if bestPiece == -1 || count < bestAvailability {
|
||||||
|
bestPiece = pieceIndex
|
||||||
|
bestAvailability = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestPiece
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePeerAvailability(peerAvailability map[string][]bool, availability []int, peerID string, have []bool, pieceCount int) {
|
||||||
|
normalized := make([]bool, pieceCount)
|
||||||
|
copy(normalized, have)
|
||||||
|
|
||||||
|
if prev, ok := peerAvailability[peerID]; ok {
|
||||||
|
same := true
|
||||||
|
for i := 0; i < pieceCount; i++ {
|
||||||
|
if prev[i] != normalized[i] {
|
||||||
|
same = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if same {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < pieceCount; i++ {
|
||||||
|
if prev[i] && availability[i] > 0 {
|
||||||
|
availability[i]--
|
||||||
|
}
|
||||||
|
if normalized[i] {
|
||||||
|
availability[i]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
peerAvailability[peerID] = normalized
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < pieceCount; i++ {
|
||||||
|
if normalized[i] {
|
||||||
|
availability[i]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
peerAvailability[peerID] = normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func releasePeerAvailability(peerAvailability map[string][]bool, availability []int, peerID string) {
|
||||||
|
prev, ok := peerAvailability[peerID]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range prev {
|
||||||
|
if prev[i] && availability[i] > 0 {
|
||||||
|
availability[i]--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(peerAvailability, peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectPendingPieceRarest(states []pieceState, availability []int, have []bool, hasInfo bool) int {
|
||||||
|
firstPending := firstPendingPiece(states)
|
||||||
|
if firstPending < 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// If peer has not sent bitfield/have yet, optimistically probe.
|
||||||
|
if !hasInfo {
|
||||||
|
return firstPending
|
||||||
|
}
|
||||||
|
|
||||||
|
bestPiece := -1
|
||||||
|
bestAvailability := 0
|
||||||
|
|
||||||
|
for pieceIndex, state := range states {
|
||||||
|
if state != piecePending {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pieceIndex >= len(have) || !have[pieceIndex] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
count := availability[pieceIndex]
|
||||||
|
if bestPiece == -1 || count < bestAvailability || (count == bestAvailability && pieceIndex < bestPiece) {
|
||||||
|
bestPiece = pieceIndex
|
||||||
|
bestAvailability = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bestPiece >= 0 {
|
||||||
|
return bestPiece
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some peers send truncated availability info; allow fallback probing.
|
||||||
|
if len(have) == 0 || len(have) < len(states) {
|
||||||
|
return firstPending
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectPendingPieceSequential(states []pieceState, have []bool, hasInfo bool) int {
|
||||||
|
firstPending := firstPendingPiece(states)
|
||||||
|
if firstPending < 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// If peer has not sent bitfield/have yet, optimistically probe.
|
||||||
|
if !hasInfo {
|
||||||
|
return firstPending
|
||||||
|
}
|
||||||
|
|
||||||
|
for pieceIndex, state := range states {
|
||||||
|
if state != piecePending {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pieceIndex >= len(have) || !have[pieceIndex] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return pieceIndex // В последовательном режиме сразу берём первый доступный
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some peers send truncated availability info; allow fallback probing.
|
||||||
|
if len(have) == 0 || len(have) < len(states) {
|
||||||
|
return firstPending
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstPendingPiece(states []pieceState) int {
|
||||||
|
for pieceIndex, state := range states {
|
||||||
|
if state == piecePending {
|
||||||
|
return pieceIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
419
internal/torrentfile/torrentfile.go
Normal file
419
internal/torrentfile/torrentfile.go
Normal file
|
|
@ -0,0 +1,419 @@
|
||||||
|
package torrentfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha1"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
"github.com/jackpal/bencode-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type bencodeTorrent struct {
|
||||||
|
Announce string `bencode:"announce"`
|
||||||
|
AnnounceList [][]string `bencode:"announce-list"`
|
||||||
|
Info bencodeInfo `bencode:"info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type bencodeInfo struct {
|
||||||
|
Pieces string `bencode:"pieces"`
|
||||||
|
PieceLength int `bencode:"piece length"`
|
||||||
|
Length int `bencode:"length"`
|
||||||
|
Files []file `bencode:"files"`
|
||||||
|
Name string `bencode:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type file struct {
|
||||||
|
Length int `bencode:"length"`
|
||||||
|
Path []string `bencode:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type File struct {
|
||||||
|
Path string
|
||||||
|
Length int
|
||||||
|
Priority int // 0 = skip, 1 = normal
|
||||||
|
}
|
||||||
|
|
||||||
|
type TorrentFile struct {
|
||||||
|
Announce string
|
||||||
|
Trackers []string
|
||||||
|
InfoHash [20]byte
|
||||||
|
PieceHashes [][20]byte
|
||||||
|
PieceLength int
|
||||||
|
Length int
|
||||||
|
Name string
|
||||||
|
Files []File
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(path string) (*TorrentFile, error) {
|
||||||
|
rawData, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
infoBytes, err := extractInfoBytes(rawData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var bto bencodeTorrent
|
||||||
|
|
||||||
|
if err := bencode.Unmarshal(bytes.NewReader(rawData), &bto); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если announce пустой, берём первый трекер из announce-list (BEP 12)
|
||||||
|
primaryAnnounce := bto.Announce
|
||||||
|
if primaryAnnounce == "" {
|
||||||
|
for _, tier := range bto.AnnounceList {
|
||||||
|
for _, tr := range tier {
|
||||||
|
if tr != "" {
|
||||||
|
primaryAnnounce = tr
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if primaryAnnounce != "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateMetadata(bto.Info); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pieceHashes, err := splitPieceHashes(bto.Info.Pieces)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
files, totalLength, err := deriveFilesAndLength(bto.Info)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validatePieceCount(totalLength, bto.Info.PieceLength, len(pieceHashes)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
infoHash := sha1.Sum(infoBytes)
|
||||||
|
trackers := collectTrackers(primaryAnnounce, bto.AnnounceList)
|
||||||
|
|
||||||
|
tf := TorrentFile{
|
||||||
|
Announce: primaryAnnounce,
|
||||||
|
Trackers: trackers,
|
||||||
|
InfoHash: infoHash,
|
||||||
|
PieceHashes: pieceHashes,
|
||||||
|
PieceLength: bto.Info.PieceLength,
|
||||||
|
Length: totalLength,
|
||||||
|
Name: bto.Info.Name,
|
||||||
|
Files: files,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &tf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func FromMetadata(infoBytes []byte, trackers []string) (*TorrentFile, error) {
|
||||||
|
var info bencodeInfo
|
||||||
|
if err := bencode.Unmarshal(bytes.NewReader(infoBytes), &info); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
primaryAnnounce := ""
|
||||||
|
if len(trackers) > 0 {
|
||||||
|
primaryAnnounce = trackers[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateMetadata(info); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pieceHashes, err := splitPieceHashes(info.Pieces)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
files, totalLength, err := deriveFilesAndLength(info)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validatePieceCount(totalLength, info.PieceLength, len(pieceHashes)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
infoHash := sha1.Sum(infoBytes)
|
||||||
|
|
||||||
|
tf := TorrentFile{
|
||||||
|
Announce: primaryAnnounce,
|
||||||
|
Trackers: trackers,
|
||||||
|
InfoHash: infoHash,
|
||||||
|
PieceHashes: pieceHashes,
|
||||||
|
PieceLength: info.PieceLength,
|
||||||
|
Length: totalLength,
|
||||||
|
Name: info.Name,
|
||||||
|
Files: files,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &tf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateMetadata(info bencodeInfo) error {
|
||||||
|
switch {
|
||||||
|
case info.Name == "":
|
||||||
|
return errors.New("torrent name is empty")
|
||||||
|
case info.PieceLength <= 0:
|
||||||
|
return errors.New("torrent piece length must be greater than zero")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case info.Length > 0 && len(info.Files) > 0:
|
||||||
|
return errors.New("torrent contains both single-file and multi-file metadata")
|
||||||
|
case info.Length <= 0 && len(info.Files) == 0:
|
||||||
|
return errors.New("torrent missing both length and files metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitPieceHashes(rawPieces string) ([][20]byte, error) {
|
||||||
|
pieces := []byte(rawPieces)
|
||||||
|
if len(pieces) == 0 {
|
||||||
|
return nil, errors.New("torrent has no piece hashes")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pieces)%sha1.Size != 0 {
|
||||||
|
return nil, fmt.Errorf("invalid pieces data size %d (must be multiple of %d)", len(pieces), sha1.Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
hashes := make([][20]byte, len(pieces)/sha1.Size)
|
||||||
|
for i := range hashes {
|
||||||
|
start := i * sha1.Size
|
||||||
|
copy(hashes[i][:], pieces[start:start+sha1.Size])
|
||||||
|
}
|
||||||
|
|
||||||
|
return hashes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveFilesAndLength(info bencodeInfo) ([]File, int, error) {
|
||||||
|
if info.Length > 0 {
|
||||||
|
return []File{{Path: info.Name, Length: info.Length, Priority: 1}}, info.Length, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
files := make([]File, 0, len(info.Files))
|
||||||
|
totalLength := 0
|
||||||
|
for _, f := range info.Files {
|
||||||
|
if f.Length <= 0 {
|
||||||
|
return nil, 0, errors.New("torrent file length must be greater than zero")
|
||||||
|
}
|
||||||
|
if len(f.Path) == 0 {
|
||||||
|
return nil, 0, errors.New("torrent file path cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := make([]string, 0, len(f.Path)+1)
|
||||||
|
parts = append(parts, info.Name)
|
||||||
|
parts = append(parts, f.Path...)
|
||||||
|
filePath := path.Join(parts...)
|
||||||
|
|
||||||
|
files = append(files, File{
|
||||||
|
Path: filePath,
|
||||||
|
Length: f.Length,
|
||||||
|
Priority: 1,
|
||||||
|
})
|
||||||
|
totalLength += f.Length
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalLength <= 0 {
|
||||||
|
return nil, 0, errors.New("torrent total length must be greater than zero")
|
||||||
|
}
|
||||||
|
|
||||||
|
return files, totalLength, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePieceCount(totalLength, pieceLength, actualPieces int) error {
|
||||||
|
expectedPieces := (totalLength + pieceLength - 1) / pieceLength
|
||||||
|
if expectedPieces != actualPieces {
|
||||||
|
return fmt.Errorf("piece hash count mismatch: expected %d, got %d", expectedPieces, actualPieces)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractInfoBytes(data []byte) ([]byte, error) {
|
||||||
|
if len(data) == 0 || data[0] != 'd' {
|
||||||
|
return nil, errors.New("torrent root must be a bencoded dictionary")
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := 1
|
||||||
|
for idx < len(data) {
|
||||||
|
if data[idx] == 'e' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
key, next, err := parseBencodeString(data, idx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
idx = next
|
||||||
|
|
||||||
|
valueStart := idx
|
||||||
|
valueEnd, err := skipBencodeValue(data, idx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(key) == "info" {
|
||||||
|
return data[valueStart:valueEnd], nil
|
||||||
|
}
|
||||||
|
idx = valueEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("torrent does not contain top-level info dictionary")
|
||||||
|
}
|
||||||
|
|
||||||
|
func skipBencodeValue(data []byte, idx int) (int, error) {
|
||||||
|
if idx >= len(data) {
|
||||||
|
return 0, errors.New("unexpected end of bencoded data")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch c := data[idx]; {
|
||||||
|
case c == 'i':
|
||||||
|
return skipBencodeInt(data, idx)
|
||||||
|
case c == 'l':
|
||||||
|
idx++
|
||||||
|
for {
|
||||||
|
if idx >= len(data) {
|
||||||
|
return 0, errors.New("unexpected end while parsing bencoded list")
|
||||||
|
}
|
||||||
|
if data[idx] == 'e' {
|
||||||
|
return idx + 1, nil
|
||||||
|
}
|
||||||
|
next, err := skipBencodeValue(data, idx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
idx = next
|
||||||
|
}
|
||||||
|
case c == 'd':
|
||||||
|
idx++
|
||||||
|
for {
|
||||||
|
if idx >= len(data) {
|
||||||
|
return 0, errors.New("unexpected end while parsing bencoded dictionary")
|
||||||
|
}
|
||||||
|
if data[idx] == 'e' {
|
||||||
|
return idx + 1, nil
|
||||||
|
}
|
||||||
|
_, next, err := parseBencodeString(data, idx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
idx = next
|
||||||
|
|
||||||
|
next, err = skipBencodeValue(data, idx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
idx = next
|
||||||
|
}
|
||||||
|
case c >= '0' && c <= '9':
|
||||||
|
_, next, err := parseBencodeString(data, idx)
|
||||||
|
return next, err
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("invalid bencode token %q at index %d", c, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func skipBencodeInt(data []byte, idx int) (int, error) {
|
||||||
|
if data[idx] != 'i' {
|
||||||
|
return 0, fmt.Errorf("expected integer token at index %d", idx)
|
||||||
|
}
|
||||||
|
idx++
|
||||||
|
if idx >= len(data) {
|
||||||
|
return 0, errors.New("unexpected end while parsing bencoded integer")
|
||||||
|
}
|
||||||
|
|
||||||
|
if data[idx] == '-' {
|
||||||
|
idx++
|
||||||
|
if idx >= len(data) {
|
||||||
|
return 0, errors.New("unexpected end after bencoded integer sign")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if data[idx] < '0' || data[idx] > '9' {
|
||||||
|
return 0, fmt.Errorf("invalid bencoded integer digit at index %d", idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx < len(data) && data[idx] != 'e' {
|
||||||
|
if data[idx] < '0' || data[idx] > '9' {
|
||||||
|
return 0, fmt.Errorf("invalid bencoded integer digit at index %d", idx)
|
||||||
|
}
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx >= len(data) || data[idx] != 'e' {
|
||||||
|
return 0, errors.New("unterminated bencoded integer")
|
||||||
|
}
|
||||||
|
|
||||||
|
return idx + 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBencodeString(data []byte, idx int) ([]byte, int, error) {
|
||||||
|
length, valueStart, err := parseBencodeStringLength(data, idx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
valueEnd := valueStart + length
|
||||||
|
if valueEnd > len(data) {
|
||||||
|
return nil, 0, errors.New("bencoded string length exceeds input size")
|
||||||
|
}
|
||||||
|
|
||||||
|
return data[valueStart:valueEnd], valueEnd, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBencodeStringLength(data []byte, idx int) (int, int, error) {
|
||||||
|
if idx >= len(data) {
|
||||||
|
return 0, 0, errors.New("unexpected end while parsing bencoded string length")
|
||||||
|
}
|
||||||
|
if data[idx] < '0' || data[idx] > '9' {
|
||||||
|
return 0, 0, fmt.Errorf("invalid bencoded string length token at index %d", idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
length := 0
|
||||||
|
for idx < len(data) && data[idx] >= '0' && data[idx] <= '9' {
|
||||||
|
length = (length * 10) + int(data[idx]-'0')
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx >= len(data) || data[idx] != ':' {
|
||||||
|
return 0, 0, errors.New("unterminated bencoded string length")
|
||||||
|
}
|
||||||
|
|
||||||
|
return length, idx + 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectTrackers(primary string, announceList [][]string) []string {
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
trackers := make([]string, 0, 1+len(announceList))
|
||||||
|
|
||||||
|
add := func(tracker string) {
|
||||||
|
if tracker == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := seen[tracker]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[tracker] = struct{}{}
|
||||||
|
trackers = append(trackers, tracker)
|
||||||
|
}
|
||||||
|
|
||||||
|
add(primary)
|
||||||
|
for _, tier := range announceList {
|
||||||
|
for _, tracker := range tier {
|
||||||
|
add(tracker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return trackers
|
||||||
|
}
|
||||||
155
internal/torrentfile/torrentfile_test.go
Normal file
155
internal/torrentfile/torrentfile_test.go
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
package torrentfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha1"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOpenParsesTorrentMetadata(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
info := "d6:lengthi5e4:name8:test.txt12:piece lengthi16384e6:pieces20:aaaaaaaaaaaaaaaaaaaa7:privatei1ee"
|
||||||
|
torrent := "d8:announce14:http://tracker4:info" + info + "e"
|
||||||
|
path := writeTempTorrent(t, torrent)
|
||||||
|
|
||||||
|
tf, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedHash := sha1.Sum([]byte(info))
|
||||||
|
if tf.InfoHash != expectedHash {
|
||||||
|
t.Fatalf("unexpected info hash: got %x want %x", tf.InfoHash, expectedHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tf.Announce != "http://tracker" {
|
||||||
|
t.Fatalf("unexpected announce: got %q", tf.Announce)
|
||||||
|
}
|
||||||
|
if len(tf.Trackers) != 1 || tf.Trackers[0] != "http://tracker" {
|
||||||
|
t.Fatalf("unexpected trackers list: %#v", tf.Trackers)
|
||||||
|
}
|
||||||
|
if tf.Name != "test.txt" {
|
||||||
|
t.Fatalf("unexpected name: got %q", tf.Name)
|
||||||
|
}
|
||||||
|
if tf.PieceLength != 16384 {
|
||||||
|
t.Fatalf("unexpected piece length: got %d", tf.PieceLength)
|
||||||
|
}
|
||||||
|
if tf.Length != 5 {
|
||||||
|
t.Fatalf("unexpected length: got %d", tf.Length)
|
||||||
|
}
|
||||||
|
if len(tf.PieceHashes) != 1 {
|
||||||
|
t.Fatalf("unexpected piece count: got %d", len(tf.PieceHashes))
|
||||||
|
}
|
||||||
|
if len(tf.Files) != 1 {
|
||||||
|
t.Fatalf("unexpected file count: got %d", len(tf.Files))
|
||||||
|
}
|
||||||
|
if got, want := tf.Files[0].Path, "test.txt"; got != want {
|
||||||
|
t.Fatalf("unexpected first file path: got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := tf.Files[0].Length, 5; got != want {
|
||||||
|
t.Fatalf("unexpected first file length: got %d want %d", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
var expectedPiece [20]byte
|
||||||
|
copy(expectedPiece[:], []byte("aaaaaaaaaaaaaaaaaaaa"))
|
||||||
|
if tf.PieceHashes[0] != expectedPiece {
|
||||||
|
t.Fatalf("unexpected piece hash: got %x want %x", tf.PieceHashes[0], expectedPiece)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenRejectsInvalidPieceHashes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
info := "d6:lengthi5e4:name8:test.txt12:piece lengthi16384e6:pieces19:aaaaaaaaaaaaaaaaaaae"
|
||||||
|
torrent := "d8:announce14:http://tracker4:info" + info + "e"
|
||||||
|
path := writeTempTorrent(t, torrent)
|
||||||
|
|
||||||
|
_, err := Open(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected Open to fail for invalid piece hash length")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenParsesMultiFileTorrent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
pieces := "aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbb"
|
||||||
|
info := "d5:filesld6:lengthi3e4:pathl5:a.txteed6:lengthi2e4:pathl5:b.txteee4:name4:root12:piece lengthi4e6:pieces40:" + pieces + "e"
|
||||||
|
torrent := "d8:announce14:http://tracker4:info" + info + "e"
|
||||||
|
path := writeTempTorrent(t, torrent)
|
||||||
|
|
||||||
|
tf, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tf.Length != 5 {
|
||||||
|
t.Fatalf("unexpected total length: got %d", tf.Length)
|
||||||
|
}
|
||||||
|
if len(tf.Files) != 2 {
|
||||||
|
t.Fatalf("unexpected file count: got %d", len(tf.Files))
|
||||||
|
}
|
||||||
|
if got, want := tf.Files[0].Path, "root/a.txt"; got != want {
|
||||||
|
t.Fatalf("unexpected file[0] path: got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := tf.Files[1].Path, "root/b.txt"; got != want {
|
||||||
|
t.Fatalf("unexpected file[1] path: got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
if len(tf.PieceHashes) != 2 {
|
||||||
|
t.Fatalf("unexpected piece hash count: got %d", len(tf.PieceHashes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenRejectsPieceCountMismatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
info := "d6:lengthi10e4:name8:test.txt12:piece lengthi4e6:pieces40:aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbe"
|
||||||
|
torrent := "d8:announce14:http://tracker4:info" + info + "e"
|
||||||
|
path := writeTempTorrent(t, torrent)
|
||||||
|
|
||||||
|
_, err := Open(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected Open to fail for piece count mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenParsesAnnounceListAndDeduplicatesTrackers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
info := "d6:lengthi5e4:name8:test.txt12:piece lengthi16384e6:pieces20:aaaaaaaaaaaaaaaaaaaae"
|
||||||
|
torrent := "d8:announce14:http://tracker13:announce-listll14:http://tracker17:udp://tracker.oneel17:udp://tracker.one17:udp://tracker.twoee4:info" + info + "e"
|
||||||
|
path := writeTempTorrent(t, torrent)
|
||||||
|
|
||||||
|
tf, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := []string{
|
||||||
|
"http://tracker",
|
||||||
|
"udp://tracker.one",
|
||||||
|
"udp://tracker.two",
|
||||||
|
}
|
||||||
|
if len(tf.Trackers) != len(expected) {
|
||||||
|
t.Fatalf("unexpected trackers length: got %d want %d (%#v)", len(tf.Trackers), len(expected), tf.Trackers)
|
||||||
|
}
|
||||||
|
for i := range expected {
|
||||||
|
if tf.Trackers[i] != expected[i] {
|
||||||
|
t.Fatalf("unexpected tracker[%d]: got %q want %q", i, tf.Trackers[i], expected[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTempTorrent(t *testing.T, data string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "test.torrent")
|
||||||
|
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
|
||||||
|
t.Fatalf("failed to write temp torrent file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
490
internal/tracker/tracker.go
Normal file
490
internal/tracker/tracker.go
Normal file
|
|
@ -0,0 +1,490 @@
|
||||||
|
package tracker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackpal/bencode-go"
|
||||||
|
"github.com/veggiedefender/torrent-client/internal/torrentfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TrackerResponse struct {
|
||||||
|
FailureReason string `bencode:"failure reason"`
|
||||||
|
Interval int `bencode:"interval"`
|
||||||
|
Peers string `bencode:"peers"`
|
||||||
|
Peers6 string `bencode:"peers6"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Peer struct {
|
||||||
|
IP net.IP
|
||||||
|
Port uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnnounceOptions struct {
|
||||||
|
PeerID [20]byte
|
||||||
|
Port uint16
|
||||||
|
Uploaded int64
|
||||||
|
Downloaded int64
|
||||||
|
NumWant int
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type httpDoer interface {
|
||||||
|
Do(req *http.Request) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPeers(ctx context.Context, tf *torrentfile.TorrentFile, opts AnnounceOptions) ([]Peer, error) {
|
||||||
|
if tf == nil {
|
||||||
|
return nil, errors.New("torrent metadata is nil")
|
||||||
|
}
|
||||||
|
return GetPeersFromURL(ctx, tf.Announce, tf.InfoHash, tf.Length, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPeersFromURL(ctx context.Context, announce string, infoHash [20]byte, length int, opts AnnounceOptions) ([]Peer, error) {
|
||||||
|
opts = normalizeOptions(opts)
|
||||||
|
|
||||||
|
announceURL, err := url.Parse(announce)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid tracker URL %q: %w", announce, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch announceURL.Scheme {
|
||||||
|
case "http", "https":
|
||||||
|
client := &http.Client{Timeout: opts.Timeout}
|
||||||
|
return getPeersWithClient(ctx, client, announceURL, infoHash, length, opts)
|
||||||
|
case "udp":
|
||||||
|
return getPeersUDP(ctx, announceURL, infoHash, length, opts)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported tracker scheme %q (only http/https/udp are supported)", announceURL.Scheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPeersWithClient(ctx context.Context, client httpDoer, announceURL *url.URL, infoHash [20]byte, length int, opts AnnounceOptions) ([]Peer, error) {
|
||||||
|
opts = normalizeOptions(opts)
|
||||||
|
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: opts.Timeout}
|
||||||
|
}
|
||||||
|
|
||||||
|
announceURLString, err := buildAnnounceURL(announceURL, infoHash, length, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, announceURLString, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("tracker returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tr TrackerResponse
|
||||||
|
|
||||||
|
err = bencode.Unmarshal(resp.Body, &tr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if tr.FailureReason != "" {
|
||||||
|
return nil, fmt.Errorf("tracker failure: %s", tr.FailureReason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим IPv4 compact peers
|
||||||
|
peers, err := ParsePeers([]byte(tr.Peers))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим IPv6 compact peers (peers6, BEP 7)
|
||||||
|
if tr.Peers6 != "" {
|
||||||
|
peers6, err := ParsePeers6([]byte(tr.Peers6))
|
||||||
|
if err == nil {
|
||||||
|
peers = mergePeers(peers, peers6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return peers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPeersUDP(ctx context.Context, announceURL *url.URL, infoHash [20]byte, length int, opts AnnounceOptions) ([]Peer, error) {
|
||||||
|
if announceURL == nil {
|
||||||
|
return nil, errors.New("tracker URL is nil")
|
||||||
|
}
|
||||||
|
if announceURL.Scheme != "udp" {
|
||||||
|
return nil, fmt.Errorf("tracker URL scheme %q is not udp", announceURL.Scheme)
|
||||||
|
}
|
||||||
|
if announceURL.Host == "" {
|
||||||
|
return nil, errors.New("tracker URL host is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
dialer := net.Dialer{Timeout: opts.Timeout}
|
||||||
|
conn, err := dialer.DialContext(ctx, "udp", announceURL.Host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(opts.Timeout)
|
||||||
|
if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
|
||||||
|
deadline = ctxDeadline
|
||||||
|
}
|
||||||
|
if err := conn.SetDeadline(deadline); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
connectTx, err := randomUint32()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
connectReq := buildUDPConnectRequest(connectTx)
|
||||||
|
|
||||||
|
var connectionID uint64
|
||||||
|
var connOK bool
|
||||||
|
for n := 0; n <= 8; n++ {
|
||||||
|
if _, err := conn.Write(connectReq[:]); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.SetReadDeadline(time.Now().Add(15 * time.Second * time.Duration(1<<n)))
|
||||||
|
resp := make([]byte, 65535)
|
||||||
|
nRead, err := conn.Read(resp)
|
||||||
|
if err == nil {
|
||||||
|
cid, errParse := parseUDPConnectResponse(resp[:nRead], connectTx)
|
||||||
|
if errParse == nil {
|
||||||
|
connectionID = cid
|
||||||
|
connOK = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !connOK {
|
||||||
|
return nil, fmt.Errorf("udp tracker connect timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
announceTx, err := randomUint32()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
key, err := randomUint32()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
announceReq, err := buildUDPAnnounceRequest(connectionID, announceTx, infoHash, length, opts, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for n := 0; n <= 8; n++ {
|
||||||
|
if _, err := conn.Write(announceReq); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.SetReadDeadline(time.Now().Add(15 * time.Second * time.Duration(1<<n)))
|
||||||
|
resp := make([]byte, 65535)
|
||||||
|
for {
|
||||||
|
nRead, err := conn.Read(resp)
|
||||||
|
if err != nil {
|
||||||
|
break // read timeout or error, retransmit
|
||||||
|
}
|
||||||
|
peers, errParse := parseUDPAnnounceResponse(resp[:nRead], announceTx)
|
||||||
|
if errParse == nil {
|
||||||
|
return peers, nil
|
||||||
|
}
|
||||||
|
// if errParse != nil, it might be a delayed packet from another transaction, so we just loop and Read again
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("udp tracker announce timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeOptions(opts AnnounceOptions) AnnounceOptions {
|
||||||
|
if opts.Port == 0 {
|
||||||
|
opts.Port = 6881
|
||||||
|
}
|
||||||
|
if opts.NumWant <= 0 {
|
||||||
|
opts.NumWant = 50
|
||||||
|
}
|
||||||
|
if opts.Timeout <= 0 {
|
||||||
|
opts.Timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAnnounceURL(baseURL *url.URL, infoHash [20]byte, length int, opts AnnounceOptions) (string, error) {
|
||||||
|
if baseURL == nil {
|
||||||
|
return "", errors.New("tracker URL is nil")
|
||||||
|
}
|
||||||
|
if baseURL.Scheme != "http" && baseURL.Scheme != "https" {
|
||||||
|
return "", fmt.Errorf("unsupported tracker scheme %q (only http/https are supported)", baseURL.Scheme)
|
||||||
|
}
|
||||||
|
if baseURL.Host == "" {
|
||||||
|
return "", errors.New("tracker URL host is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
uploaded := opts.Uploaded
|
||||||
|
if uploaded < 0 {
|
||||||
|
uploaded = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
downloaded := opts.Downloaded
|
||||||
|
if downloaded < 0 {
|
||||||
|
downloaded = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
left := int64(length) - downloaded
|
||||||
|
if left < 0 {
|
||||||
|
left = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := make([]string, 0, 10)
|
||||||
|
if baseURL.RawQuery != "" {
|
||||||
|
parts = append(parts, baseURL.RawQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts = append(parts,
|
||||||
|
"info_hash="+escapeBinary(infoHash[:]),
|
||||||
|
"peer_id="+escapeBinary(opts.PeerID[:]),
|
||||||
|
"port="+strconv.Itoa(int(opts.Port)),
|
||||||
|
"uploaded="+strconv.FormatInt(uploaded, 10),
|
||||||
|
"downloaded="+strconv.FormatInt(downloaded, 10),
|
||||||
|
"left="+strconv.FormatInt(left, 10),
|
||||||
|
"compact=1",
|
||||||
|
"numwant="+strconv.Itoa(opts.NumWant),
|
||||||
|
)
|
||||||
|
|
||||||
|
cloned := *baseURL
|
||||||
|
cloned.RawQuery = strings.Join(parts, "&")
|
||||||
|
return cloned.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildUDPConnectRequest(transactionID uint32) [16]byte {
|
||||||
|
var req [16]byte
|
||||||
|
binary.BigEndian.PutUint64(req[0:8], 0x41727101980)
|
||||||
|
binary.BigEndian.PutUint32(req[8:12], 0)
|
||||||
|
binary.BigEndian.PutUint32(req[12:16], transactionID)
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUDPConnectResponse(payload []byte, expectedTransactionID uint32) (uint64, error) {
|
||||||
|
if len(payload) < 8 {
|
||||||
|
return 0, fmt.Errorf("udp tracker connect response too short: %d bytes", len(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
action := binary.BigEndian.Uint32(payload[0:4])
|
||||||
|
transactionID := binary.BigEndian.Uint32(payload[4:8])
|
||||||
|
if transactionID != expectedTransactionID {
|
||||||
|
return 0, fmt.Errorf("udp tracker connect transaction mismatch: expected %d, got %d", expectedTransactionID, transactionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if action == 3 {
|
||||||
|
return 0, parseUDPTrackerError(payload)
|
||||||
|
}
|
||||||
|
if action != 0 {
|
||||||
|
return 0, fmt.Errorf("unexpected udp tracker connect action %d", action)
|
||||||
|
}
|
||||||
|
if len(payload) < 16 {
|
||||||
|
return 0, fmt.Errorf("udp tracker connect response too short: %d bytes", len(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
return binary.BigEndian.Uint64(payload[8:16]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildUDPAnnounceRequest(connectionID uint64, transactionID uint32, infoHash [20]byte, length int, opts AnnounceOptions, key uint32) ([]byte, error) {
|
||||||
|
|
||||||
|
uploaded := opts.Uploaded
|
||||||
|
if uploaded < 0 {
|
||||||
|
uploaded = 0
|
||||||
|
}
|
||||||
|
downloaded := opts.Downloaded
|
||||||
|
if downloaded < 0 {
|
||||||
|
downloaded = 0
|
||||||
|
}
|
||||||
|
left := int64(length) - downloaded
|
||||||
|
if left < 0 {
|
||||||
|
left = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
req := make([]byte, 98)
|
||||||
|
binary.BigEndian.PutUint64(req[0:8], connectionID)
|
||||||
|
binary.BigEndian.PutUint32(req[8:12], 1)
|
||||||
|
binary.BigEndian.PutUint32(req[12:16], transactionID)
|
||||||
|
copy(req[16:36], infoHash[:])
|
||||||
|
copy(req[36:56], opts.PeerID[:])
|
||||||
|
binary.BigEndian.PutUint64(req[56:64], uint64(downloaded))
|
||||||
|
binary.BigEndian.PutUint64(req[64:72], uint64(left))
|
||||||
|
binary.BigEndian.PutUint64(req[72:80], uint64(uploaded))
|
||||||
|
binary.BigEndian.PutUint32(req[80:84], 1) // event: 1 (started) instead of 0 (none)
|
||||||
|
binary.BigEndian.PutUint32(req[84:88], 0) // IP address (default 0)
|
||||||
|
binary.BigEndian.PutUint32(req[88:92], key)
|
||||||
|
binary.BigEndian.PutUint32(req[92:96], uint32(int32(opts.NumWant)))
|
||||||
|
binary.BigEndian.PutUint16(req[96:98], opts.Port)
|
||||||
|
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUDPAnnounceResponse(payload []byte, expectedTransactionID uint32) ([]Peer, error) {
|
||||||
|
if len(payload) < 8 {
|
||||||
|
return nil, fmt.Errorf("udp tracker announce response too short: %d bytes", len(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
action := binary.BigEndian.Uint32(payload[0:4])
|
||||||
|
transactionID := binary.BigEndian.Uint32(payload[4:8])
|
||||||
|
if transactionID != expectedTransactionID {
|
||||||
|
return nil, fmt.Errorf("udp tracker announce transaction mismatch: expected %d, got %d", expectedTransactionID, transactionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if action == 3 {
|
||||||
|
return nil, parseUDPTrackerError(payload)
|
||||||
|
}
|
||||||
|
if action != 1 {
|
||||||
|
return nil, fmt.Errorf("unexpected udp tracker announce action %d", action)
|
||||||
|
}
|
||||||
|
if len(payload) < 20 {
|
||||||
|
return nil, fmt.Errorf("udp tracker announce response too short: %d bytes", len(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParsePeers(payload[20:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUDPTrackerError(payload []byte) error {
|
||||||
|
if len(payload) > 8 {
|
||||||
|
return fmt.Errorf("udp tracker failure: %s", strings.TrimSpace(string(payload[8:])))
|
||||||
|
}
|
||||||
|
return errors.New("udp tracker failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeBinary(data []byte) string {
|
||||||
|
const hex = "0123456789ABCDEF"
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for _, c := range data {
|
||||||
|
if isURLUnreserved(c) {
|
||||||
|
b.WriteByte(c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteByte('%')
|
||||||
|
b.WriteByte(hex[c>>4])
|
||||||
|
b.WriteByte(hex[c&0x0F])
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isURLUnreserved(c byte) bool {
|
||||||
|
switch {
|
||||||
|
case c >= 'a' && c <= 'z':
|
||||||
|
return true
|
||||||
|
case c >= 'A' && c <= 'Z':
|
||||||
|
return true
|
||||||
|
case c >= '0' && c <= '9':
|
||||||
|
return true
|
||||||
|
case c == '-', c == '.', c == '_', c == '~':
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePeers parses compact IPv4 peers (6 bytes per peer: 4 IP + 2 port).
|
||||||
|
func ParsePeers(data []byte) ([]Peer, error) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return []Peer{}, nil
|
||||||
|
}
|
||||||
|
if len(data)%6 != 0 {
|
||||||
|
return nil, fmt.Errorf("invalid compact peers length %d", len(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
var peers []Peer
|
||||||
|
|
||||||
|
for i := 0; i < len(data); i += 6 {
|
||||||
|
ip := make(net.IP, 4)
|
||||||
|
copy(ip, data[i:i+4])
|
||||||
|
|
||||||
|
port := uint16(data[i+4])<<8 |
|
||||||
|
uint16(data[i+5])
|
||||||
|
|
||||||
|
peers = append(peers, Peer{
|
||||||
|
IP: ip,
|
||||||
|
Port: port,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return peers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePeers6 парсит compact IPv6 peers (BEP 7): 18 байт на пир (16 IP + 2 порт).
|
||||||
|
func ParsePeers6(data []byte) ([]Peer, error) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return []Peer{}, nil
|
||||||
|
}
|
||||||
|
if len(data)%18 != 0 {
|
||||||
|
return nil, fmt.Errorf("invalid compact peers6 length %d (must be multiple of 18)", len(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
peers := make([]Peer, 0, len(data)/18)
|
||||||
|
for i := 0; i < len(data); i += 18 {
|
||||||
|
ip := make(net.IP, 16)
|
||||||
|
copy(ip, data[i:i+16])
|
||||||
|
port := uint16(data[i+16])<<8 | uint16(data[i+17])
|
||||||
|
peers = append(peers, Peer{IP: ip, Port: port})
|
||||||
|
}
|
||||||
|
return peers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergePeers объединяет два списка пиров, дедуплицируя по IP:порт.
|
||||||
|
func mergePeers(a, b []Peer) []Peer {
|
||||||
|
seen := make(map[string]struct{}, len(a)+len(b))
|
||||||
|
result := make([]Peer, 0, len(a)+len(b))
|
||||||
|
|
||||||
|
add := func(p Peer) {
|
||||||
|
key := net.JoinHostPort(p.IP.String(), strconv.Itoa(int(p.Port)))
|
||||||
|
if _, ok := seen[key]; !ok {
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
result = append(result, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range a {
|
||||||
|
add(p)
|
||||||
|
}
|
||||||
|
for _, p := range b {
|
||||||
|
add(p)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomUint32() (uint32, error) {
|
||||||
|
var b [4]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return binary.BigEndian.Uint32(b[:]), nil
|
||||||
|
}
|
||||||
309
internal/tracker/tracker_test.go
Normal file
309
internal/tracker/tracker_test.go
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
package tracker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/veggiedefender/torrent-client/internal/torrentfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParsePeers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
raw := []byte{127, 0, 0, 1, 0x1A, 0xE1}
|
||||||
|
peers, err := ParsePeers(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parsePeers returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(peers) != 1 {
|
||||||
|
t.Fatalf("unexpected peer count: got %d", len(peers))
|
||||||
|
}
|
||||||
|
if got, want := peers[0].IP.String(), "127.0.0.1"; got != want {
|
||||||
|
t.Fatalf("unexpected peer IP: got %s want %s", got, want)
|
||||||
|
}
|
||||||
|
if got, want := peers[0].Port, uint16(6881); got != want {
|
||||||
|
t.Fatalf("unexpected peer port: got %d want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePeersRejectsInvalidLength(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := ParsePeers([]byte{1, 2, 3, 4, 5})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected ParsePeers to fail for invalid compact peer data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPeersBuildsAnnounceAndParsesResponse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var infoHash [20]byte
|
||||||
|
copy(infoHash[:], []byte("abcdefghijklmnopqrst"))
|
||||||
|
|
||||||
|
var peerID [20]byte
|
||||||
|
copy(peerID[:], []byte("-ZT0001-123456789012"))
|
||||||
|
|
||||||
|
tf := &torrentfile.TorrentFile{
|
||||||
|
Announce: "http://tracker.test/announce",
|
||||||
|
InfoHash: infoHash,
|
||||||
|
Length: 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
var seenRequest *http.Request
|
||||||
|
client := fakeClient{
|
||||||
|
do: func(req *http.Request) (*http.Response, error) {
|
||||||
|
seenRequest = req
|
||||||
|
payload := []byte("d8:intervali1800e5:peers6:\x7f\x00\x00\x01\x1a\xe1e")
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: io.NopCloser(bytes.NewReader(payload)),
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
announceURL, err := url.Parse(tf.Announce)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse announce URL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
peers, err := getPeersWithClient(context.Background(), client, announceURL, tf.InfoHash, tf.Length, AnnounceOptions{
|
||||||
|
PeerID: peerID,
|
||||||
|
Port: 6881,
|
||||||
|
Downloaded: 100,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPeers returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if seenRequest == nil {
|
||||||
|
t.Fatal("expected tracker request to be sent")
|
||||||
|
}
|
||||||
|
|
||||||
|
q := seenRequest.URL.Query()
|
||||||
|
if got := q.Get("compact"); got != "1" {
|
||||||
|
t.Fatalf("unexpected compact value: got %q", got)
|
||||||
|
}
|
||||||
|
if got := q.Get("port"); got != "6881" {
|
||||||
|
t.Fatalf("unexpected port value: got %q", got)
|
||||||
|
}
|
||||||
|
if got := q.Get("left"); got != "900" {
|
||||||
|
t.Fatalf("unexpected left value: got %q", got)
|
||||||
|
}
|
||||||
|
if got := q.Get("numwant"); got != "50" {
|
||||||
|
t.Fatalf("unexpected numwant value: got %q", got)
|
||||||
|
}
|
||||||
|
if got := []byte(q.Get("info_hash")); !equalBytes(got, infoHash[:]) {
|
||||||
|
t.Fatalf("unexpected info_hash value: got %x want %x", got, infoHash)
|
||||||
|
}
|
||||||
|
if got := []byte(q.Get("peer_id")); !equalBytes(got, peerID[:]) {
|
||||||
|
t.Fatalf("unexpected peer_id value: got %x want %x", got, peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(peers) != 1 {
|
||||||
|
t.Fatalf("unexpected peer count: got %d", len(peers))
|
||||||
|
}
|
||||||
|
if got, want := peers[0].Port, uint16(6881); got != want {
|
||||||
|
t.Fatalf("unexpected peer port: got %d want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAnnounceURLEncodesBinaryValues(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var infoHash [20]byte
|
||||||
|
copy(infoHash[:], []byte{0x00, 0xFF, 0x2F, 'A', 0x20})
|
||||||
|
|
||||||
|
var peerID [20]byte
|
||||||
|
copy(peerID[:], []byte("-ZT0001-\x00\xFFabc"))
|
||||||
|
|
||||||
|
tf := &torrentfile.TorrentFile{
|
||||||
|
Announce: "http://tracker.test/announce?token=abc",
|
||||||
|
InfoHash: infoHash,
|
||||||
|
Length: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL, err := url.Parse(tf.Announce)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse announce URL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
announceURL, err := buildAnnounceURL(baseURL, tf.InfoHash, tf.Length, AnnounceOptions{
|
||||||
|
PeerID: peerID,
|
||||||
|
Port: 6881,
|
||||||
|
NumWant: 42,
|
||||||
|
Uploaded: -5,
|
||||||
|
Downloaded: -7,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildAnnounceURL returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(announceURL, "token=abc") {
|
||||||
|
t.Fatalf("announce URL did not preserve existing query params: %s", announceURL)
|
||||||
|
}
|
||||||
|
if !strings.Contains(announceURL, "info_hash=%00%FF%2FA%20") {
|
||||||
|
t.Fatalf("announce URL did not encode binary info hash correctly: %s", announceURL)
|
||||||
|
}
|
||||||
|
if !strings.Contains(announceURL, "peer_id=-ZT0001-%00%FFabc") {
|
||||||
|
t.Fatalf("announce URL did not encode binary peer ID correctly: %s", announceURL)
|
||||||
|
}
|
||||||
|
if !strings.Contains(announceURL, "uploaded=0") {
|
||||||
|
t.Fatalf("announce URL did not clamp uploaded value: %s", announceURL)
|
||||||
|
}
|
||||||
|
if !strings.Contains(announceURL, "downloaded=0") {
|
||||||
|
t.Fatalf("announce URL did not clamp downloaded value: %s", announceURL)
|
||||||
|
}
|
||||||
|
if !strings.Contains(announceURL, "left=10") {
|
||||||
|
t.Fatalf("announce URL did not compute left bytes correctly: %s", announceURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAnnounceURLRejectsUnsupportedScheme(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
baseURL, err := url.Parse("udp://tracker.test:6969/announce")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse announce URL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = buildAnnounceURL(baseURL, [20]byte{}, 10, AnnounceOptions{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected buildAnnounceURL to reject unsupported tracker scheme")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPeersReturnsTrackerFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tf := &torrentfile.TorrentFile{
|
||||||
|
Announce: "http://tracker.test/announce",
|
||||||
|
Length: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
client := fakeClient{
|
||||||
|
do: func(req *http.Request) (*http.Response, error) {
|
||||||
|
payload := []byte("d14:failure reason11:bad requeste")
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: io.NopCloser(bytes.NewReader(payload)),
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
announceURL, err := url.Parse(tf.Announce)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse announce URL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = getPeersWithClient(context.Background(), client, announceURL, tf.InfoHash, tf.Length, AnnounceOptions{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected getPeersWithClient to return tracker failure error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseUDPConnectResponse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
transactionID := uint32(42)
|
||||||
|
connectionID := uint64(0x0102030405060708)
|
||||||
|
|
||||||
|
payload := make([]byte, 16)
|
||||||
|
binary.BigEndian.PutUint32(payload[0:4], 0)
|
||||||
|
binary.BigEndian.PutUint32(payload[4:8], transactionID)
|
||||||
|
binary.BigEndian.PutUint64(payload[8:16], connectionID)
|
||||||
|
|
||||||
|
got, err := parseUDPConnectResponse(payload, transactionID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseUDPConnectResponse returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got != connectionID {
|
||||||
|
t.Fatalf("unexpected connection ID: got %x want %x", got, connectionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseUDPAnnounceResponse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
transactionID := uint32(99)
|
||||||
|
payload := make([]byte, 20+6)
|
||||||
|
binary.BigEndian.PutUint32(payload[0:4], 1)
|
||||||
|
binary.BigEndian.PutUint32(payload[4:8], transactionID)
|
||||||
|
binary.BigEndian.PutUint32(payload[8:12], 1800)
|
||||||
|
binary.BigEndian.PutUint32(payload[12:16], 10)
|
||||||
|
binary.BigEndian.PutUint32(payload[16:20], 20)
|
||||||
|
copy(payload[20:], []byte{127, 0, 0, 1, 0x1A, 0xE1})
|
||||||
|
|
||||||
|
peers, err := parseUDPAnnounceResponse(payload, transactionID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseUDPAnnounceResponse returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(peers) != 1 {
|
||||||
|
t.Fatalf("unexpected peer count: got %d", len(peers))
|
||||||
|
}
|
||||||
|
if got, want := peers[0].Port, uint16(6881); got != want {
|
||||||
|
t.Fatalf("unexpected peer port: got %d want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildUDPAnnounceRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tf := &torrentfile.TorrentFile{
|
||||||
|
Length: 100,
|
||||||
|
}
|
||||||
|
copy(tf.InfoHash[:], []byte("abcdefghijklmnopqrst"))
|
||||||
|
|
||||||
|
var peerID [20]byte
|
||||||
|
copy(peerID[:], []byte("-ZT0001-123456789012"))
|
||||||
|
|
||||||
|
req, err := buildUDPAnnounceRequest(0x0102030405060708, 42, tf.InfoHash, tf.Length, AnnounceOptions{
|
||||||
|
PeerID: peerID,
|
||||||
|
Port: 6881,
|
||||||
|
Uploaded: -1,
|
||||||
|
Downloaded: 20,
|
||||||
|
NumWant: 50,
|
||||||
|
}, 0xAABBCCDD)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildUDPAnnounceRequest returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req) != 98 {
|
||||||
|
t.Fatalf("unexpected announce request length: got %d", len(req))
|
||||||
|
}
|
||||||
|
if got, want := binary.BigEndian.Uint32(req[8:12]), uint32(1); got != want {
|
||||||
|
t.Fatalf("unexpected action: got %d want %d", got, want)
|
||||||
|
}
|
||||||
|
if got, want := binary.BigEndian.Uint64(req[64:72]), uint64(80); got != want {
|
||||||
|
t.Fatalf("unexpected left bytes: got %d want %d", got, want)
|
||||||
|
}
|
||||||
|
if got, want := binary.BigEndian.Uint64(req[72:80]), uint64(0); got != want {
|
||||||
|
t.Fatalf("unexpected uploaded bytes: got %d want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeClient struct {
|
||||||
|
do func(req *http.Request) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c fakeClient) Do(req *http.Request) (*http.Response, error) {
|
||||||
|
return c.do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalBytes(a, b []byte) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
9
internal/version/version.go
Normal file
9
internal/version/version.go
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
package version
|
||||||
|
|
||||||
|
// Эти переменные инжектируются при сборке через ldflags:
|
||||||
|
// go build -ldflags="-X 'github.com/veggiedefender/torrent-client/internal/version.Version=1.2.0' -X 'github.com/veggiedefender/torrent-client/internal/version.BuildDate=2026-07-16'"
|
||||||
|
var (
|
||||||
|
Version = "dev"
|
||||||
|
BuildDate = "unknown"
|
||||||
|
GitCommit = "unknown"
|
||||||
|
)
|
||||||
45
scripts/build-all.sh
Executable file
45
scripts/build-all.sh
Executable file
|
|
@ -0,0 +1,45 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
DIST_DIR="${ROOT_DIR}/dist"
|
||||||
|
|
||||||
|
mkdir -p "${DIST_DIR}"
|
||||||
|
: "${GOCACHE:=/tmp/go-build}"
|
||||||
|
mkdir -p "${GOCACHE}"
|
||||||
|
|
||||||
|
TARGETS=(
|
||||||
|
"darwin amd64"
|
||||||
|
"darwin arm64"
|
||||||
|
"linux amd64"
|
||||||
|
"linux arm64"
|
||||||
|
"windows amd64"
|
||||||
|
)
|
||||||
|
|
||||||
|
host_os="$(go env GOOS)"
|
||||||
|
host_arch="$(go env GOARCH)"
|
||||||
|
build_failed=0
|
||||||
|
|
||||||
|
for target in "${TARGETS[@]}"; do
|
||||||
|
read -r goos goarch <<<"${target}"
|
||||||
|
|
||||||
|
ext=""
|
||||||
|
if [[ "${goos}" == "windows" ]]; then
|
||||||
|
ext=".exe"
|
||||||
|
fi
|
||||||
|
|
||||||
|
output="${DIST_DIR}/ztorrent-${goos}-${goarch}${ext}"
|
||||||
|
rm -f "${output}"
|
||||||
|
echo "Building ${output}"
|
||||||
|
|
||||||
|
if ! GOCACHE="${GOCACHE}" CGO_ENABLED=0 GOOS="${goos}" GOARCH="${goarch}" go build -o "${output}" ./cmd/torrent-client; then
|
||||||
|
echo "Failed to build target ${goos}/${goarch}"
|
||||||
|
build_failed=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "${build_failed}" -ne 0 ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Build complete. Artifacts are in ${DIST_DIR}"
|
||||||
535
ui/logo.go
Normal file
535
ui/logo.go
Normal file
|
|
@ -0,0 +1,535 @@
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Стили и цвета логотипа ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var (
|
||||||
|
logoColorGray = lipgloss.Color("#444444")
|
||||||
|
logoColorRed = lipgloss.Color("#ff4444")
|
||||||
|
logoColorYellow = lipgloss.Color("#ffff44")
|
||||||
|
logoColorGreen = lipgloss.Color("#44ff44")
|
||||||
|
logoColorWhite = lipgloss.Color("#ffffff")
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
logoStyleIdle = lipgloss.NewStyle().Foreground(logoColorGray)
|
||||||
|
logoStyleDownloading = lipgloss.NewStyle().Foreground(logoColorYellow)
|
||||||
|
logoStyleVerifying = lipgloss.NewStyle().Foreground(logoColorYellow)
|
||||||
|
logoStyleCompleted = lipgloss.NewStyle().Foreground(logoColorGreen)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Состояние узла ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type logoNodeState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
logoStateIdle logoNodeState = iota
|
||||||
|
logoStateDownloading
|
||||||
|
logoStateVerifying
|
||||||
|
logoStateCompleted
|
||||||
|
logoStateFading
|
||||||
|
logoStateOutline
|
||||||
|
)
|
||||||
|
|
||||||
|
type logoNode struct {
|
||||||
|
ID int
|
||||||
|
X, Y float64
|
||||||
|
State logoNodeState
|
||||||
|
StateTime time.Time
|
||||||
|
TargetDuration time.Duration
|
||||||
|
IsOutline bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLogoNode(id int, x, y float64) *logoNode {
|
||||||
|
return &logoNode{
|
||||||
|
ID: id,
|
||||||
|
X: x,
|
||||||
|
Y: y,
|
||||||
|
State: logoStateIdle,
|
||||||
|
StateTime: time.Now(),
|
||||||
|
IsOutline: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *logoNode) setState(state logoNodeState, now time.Time) {
|
||||||
|
n.State = state
|
||||||
|
n.StateTime = now
|
||||||
|
|
||||||
|
switch state {
|
||||||
|
case logoStateVerifying:
|
||||||
|
n.TargetDuration = 3 * time.Second
|
||||||
|
case logoStateCompleted:
|
||||||
|
n.TargetDuration = 1 * time.Second
|
||||||
|
case logoStateFading:
|
||||||
|
n.TargetDuration = 1 * time.Second
|
||||||
|
default:
|
||||||
|
n.TargetDuration = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *logoNode) Render(now time.Time) (string, lipgloss.Style) {
|
||||||
|
char := "•"
|
||||||
|
style := logoStyleIdle
|
||||||
|
|
||||||
|
switch n.State {
|
||||||
|
case logoStateIdle:
|
||||||
|
style = logoStyleIdle
|
||||||
|
case logoStateDownloading:
|
||||||
|
style = logoStyleDownloading
|
||||||
|
case logoStateVerifying:
|
||||||
|
// Плавный переход от желтого (255, 255, 68) к зеленому (68, 255, 68)
|
||||||
|
t := float64(now.Sub(n.StateTime)) / float64(n.TargetDuration)
|
||||||
|
if t > 1.0 {
|
||||||
|
t = 1.0
|
||||||
|
}
|
||||||
|
r := int(255.0 - (255.0-68.0)*t)
|
||||||
|
g := 255
|
||||||
|
b := 68
|
||||||
|
hex := fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
||||||
|
style = lipgloss.NewStyle().Foreground(lipgloss.Color(hex))
|
||||||
|
case logoStateCompleted:
|
||||||
|
style = logoStyleCompleted
|
||||||
|
case logoStateFading:
|
||||||
|
t := float64(now.Sub(n.StateTime)) / float64(n.TargetDuration)
|
||||||
|
if t > 1.0 {
|
||||||
|
t = 1.0
|
||||||
|
}
|
||||||
|
r := 68
|
||||||
|
g := int(255.0 - (255.0-68.0)*t)
|
||||||
|
b := 68
|
||||||
|
hex := fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
||||||
|
style = lipgloss.NewStyle().Foreground(lipgloss.Color(hex))
|
||||||
|
case logoStateOutline:
|
||||||
|
style = lipgloss.NewStyle().Foreground(logoColorWhite)
|
||||||
|
}
|
||||||
|
|
||||||
|
return char, style
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Частицы ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type logoParticleType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
logoTypeDownload logoParticleType = iota
|
||||||
|
logoTypeUpload
|
||||||
|
)
|
||||||
|
|
||||||
|
type logoParticle struct {
|
||||||
|
Type logoParticleType
|
||||||
|
X, Y float64
|
||||||
|
StartX float64
|
||||||
|
StartY float64
|
||||||
|
TargetX float64
|
||||||
|
TargetY float64
|
||||||
|
Speed float64
|
||||||
|
Progress float64 // 0.0 до 1.0
|
||||||
|
Angle float64 // Для дрифта
|
||||||
|
Active bool
|
||||||
|
TargetID int // ID целевого узла (для частиц загрузки)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLogoParticle(pType logoParticleType, startX, startY, targetX, targetY float64, targetID int) *logoParticle {
|
||||||
|
var speed float64
|
||||||
|
if pType == logoTypeDownload {
|
||||||
|
speed = 0.5 + rand.Float64()*0.5
|
||||||
|
} else {
|
||||||
|
speed = 0.25 + rand.Float64()*0.25
|
||||||
|
}
|
||||||
|
|
||||||
|
return &logoParticle{
|
||||||
|
Type: pType,
|
||||||
|
X: startX,
|
||||||
|
Y: startY,
|
||||||
|
StartX: startX,
|
||||||
|
StartY: startY,
|
||||||
|
TargetX: targetX,
|
||||||
|
TargetY: targetY,
|
||||||
|
Speed: speed,
|
||||||
|
Progress: 0.0,
|
||||||
|
Angle: rand.Float64() * math.Pi * 2,
|
||||||
|
Active: true,
|
||||||
|
TargetID: targetID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *logoParticle) Update(dt float64) {
|
||||||
|
if !p.Active {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Progress += p.Speed * dt
|
||||||
|
if p.Progress >= 1.0 {
|
||||||
|
p.Progress = 1.0
|
||||||
|
p.Active = false
|
||||||
|
p.X = p.TargetX
|
||||||
|
p.Y = p.TargetY
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var eased float64
|
||||||
|
if p.Type == logoTypeDownload {
|
||||||
|
t := p.Progress
|
||||||
|
eased = 1.0 - math.Pow(1.0-t, 3.0)
|
||||||
|
} else {
|
||||||
|
t := p.Progress
|
||||||
|
eased = math.Pow(t, 2.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseX := p.StartX + (p.TargetX-p.StartX)*eased
|
||||||
|
baseY := p.StartY + (p.TargetY-p.StartY)*eased
|
||||||
|
|
||||||
|
p.X = baseX
|
||||||
|
p.Y = baseY
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *logoParticle) Render() (string, lipgloss.Style) {
|
||||||
|
char := "·"
|
||||||
|
if p.Type == logoTypeUpload {
|
||||||
|
r := int(170.0 - (170.0-51.0)*p.Progress)
|
||||||
|
g := int(255.0 - (255.0-51.0)*p.Progress)
|
||||||
|
b := int(170.0 - (170.0-51.0)*p.Progress)
|
||||||
|
hex := fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
||||||
|
return char, lipgloss.NewStyle().Foreground(lipgloss.Color(hex))
|
||||||
|
}
|
||||||
|
return char, lipgloss.NewStyle().Foreground(logoColorRed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Движок логотипа ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type logoEnginePhase int
|
||||||
|
|
||||||
|
const (
|
||||||
|
logoPhaseAssemble logoEnginePhase = iota
|
||||||
|
logoPhaseVerify
|
||||||
|
logoPhaseDisperse
|
||||||
|
logoPhaseWait
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogoEngine struct {
|
||||||
|
Nodes []*logoNode
|
||||||
|
Particles []*logoParticle
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
LastUpdate time.Time
|
||||||
|
Phase logoEnginePhase
|
||||||
|
PhaseTime float64
|
||||||
|
TargetNodeQueue []int
|
||||||
|
SpawnAccumulator float64
|
||||||
|
Grid [][]string
|
||||||
|
|
||||||
|
// Pre-baked frames
|
||||||
|
frames []string
|
||||||
|
frameIndex int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *LogoEngine) ensureGrid(w, h int) {
|
||||||
|
if len(e.Grid) < h {
|
||||||
|
newGrid := make([][]string, h)
|
||||||
|
copy(newGrid, e.Grid)
|
||||||
|
e.Grid = newGrid
|
||||||
|
}
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
if len(e.Grid[y]) < w {
|
||||||
|
newRow := make([]string, w)
|
||||||
|
copy(newRow, e.Grid[y])
|
||||||
|
e.Grid[y] = newRow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLogoEngine(width, height int, offsetX, offsetY float64) *LogoEngine {
|
||||||
|
e := &LogoEngine{
|
||||||
|
Nodes: generateZNodes(offsetX, offsetY),
|
||||||
|
Particles: make([]*logoParticle, 0),
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
LastUpdate: time.Now(),
|
||||||
|
}
|
||||||
|
e.startAssemblePhase(e.LastUpdate)
|
||||||
|
e.bakeFrames()
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
const logoBakeFrames = 180 // ~18 секунд полного цикла при 10 FPS
|
||||||
|
|
||||||
|
func (e *LogoEngine) bakeFrames() {
|
||||||
|
now := time.Unix(0, 0)
|
||||||
|
e.frames = make([]string, logoBakeFrames)
|
||||||
|
for f := 0; f < logoBakeFrames; f++ {
|
||||||
|
e.simulateStep(now, 0.1)
|
||||||
|
now = now.Add(100 * time.Millisecond)
|
||||||
|
e.frames[f] = e.renderNow(now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *LogoEngine) simulateStep(now time.Time, dt float64) {
|
||||||
|
e.PhaseTime += dt
|
||||||
|
switch e.Phase {
|
||||||
|
case logoPhaseAssemble:
|
||||||
|
spawnRate := float64(len(e.Nodes)) / 2.0
|
||||||
|
e.SpawnAccumulator += spawnRate * dt
|
||||||
|
spawnCount := int(e.SpawnAccumulator)
|
||||||
|
e.SpawnAccumulator -= float64(spawnCount)
|
||||||
|
for i := 0; i < spawnCount && len(e.TargetNodeQueue) > 0; i++ {
|
||||||
|
targetID := e.TargetNodeQueue[0]
|
||||||
|
e.TargetNodeQueue = e.TargetNodeQueue[1:]
|
||||||
|
e.spawnDownloadParticleTo(e.Nodes[targetID])
|
||||||
|
}
|
||||||
|
activeDL := 0
|
||||||
|
for _, p := range e.Particles {
|
||||||
|
if p.Type == logoTypeDownload && p.Active {
|
||||||
|
activeDL++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(e.TargetNodeQueue) == 0 && activeDL == 0 {
|
||||||
|
e.Phase = logoPhaseVerify
|
||||||
|
e.PhaseTime = 0
|
||||||
|
for _, n := range e.Nodes {
|
||||||
|
if !n.IsOutline {
|
||||||
|
n.setState(logoStateVerifying, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case logoPhaseVerify:
|
||||||
|
if e.PhaseTime >= 3.0 {
|
||||||
|
e.Phase = logoPhaseDisperse
|
||||||
|
e.PhaseTime = 0
|
||||||
|
for _, n := range e.Nodes {
|
||||||
|
if !n.IsOutline {
|
||||||
|
n.setState(logoStateCompleted, now)
|
||||||
|
e.spawnUploadParticle(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case logoPhaseDisperse:
|
||||||
|
if e.PhaseTime >= 1.0 {
|
||||||
|
for _, n := range e.Nodes {
|
||||||
|
if !n.IsOutline {
|
||||||
|
n.setState(logoStateFading, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.Phase = logoPhaseWait
|
||||||
|
e.PhaseTime = 0
|
||||||
|
}
|
||||||
|
case logoPhaseWait:
|
||||||
|
if e.PhaseTime >= 2.0 {
|
||||||
|
e.startAssemblePhase(now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeParticles := e.Particles[:0]
|
||||||
|
for _, p := range e.Particles {
|
||||||
|
p.Update(dt)
|
||||||
|
if !p.Active && p.Type == logoTypeDownload {
|
||||||
|
if p.TargetID >= 0 && p.TargetID < len(e.Nodes) {
|
||||||
|
node := e.Nodes[p.TargetID]
|
||||||
|
if node.State == logoStateIdle {
|
||||||
|
node.setState(logoStateDownloading, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.Active {
|
||||||
|
activeParticles = append(activeParticles, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.Particles = activeParticles
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *LogoEngine) renderNow(now time.Time) string {
|
||||||
|
e.ensureGrid(e.Width, e.Height)
|
||||||
|
for y := 0; y < e.Height; y++ {
|
||||||
|
for x := 0; x < e.Width; x++ {
|
||||||
|
e.Grid[y][x] = " "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range e.Nodes {
|
||||||
|
if !n.IsOutline {
|
||||||
|
x, y := int(n.X+0.5), int(n.Y+0.5)
|
||||||
|
if x >= 0 && x < e.Width && y >= 0 && y < e.Height {
|
||||||
|
char, style := n.Render(now)
|
||||||
|
e.Grid[y][x] = style.Render(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range e.Particles {
|
||||||
|
x, y := int(p.X+0.5), int(p.Y+0.5)
|
||||||
|
if x >= 0 && x < e.Width && y >= 0 && y < e.Height {
|
||||||
|
char, style := p.Render()
|
||||||
|
e.Grid[y][x] = style.Render(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range e.Nodes {
|
||||||
|
if n.IsOutline {
|
||||||
|
x, y := int(n.X+0.5), int(n.Y+0.5)
|
||||||
|
if x >= 0 && x < e.Width && y >= 0 && y < e.Height {
|
||||||
|
char, style := n.Render(now)
|
||||||
|
e.Grid[y][x] = style.Render(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.Grow(e.Height * e.Width * 5)
|
||||||
|
for y := 0; y < e.Height; y++ {
|
||||||
|
sb.WriteString(strings.Join(e.Grid[y][:e.Width], ""))
|
||||||
|
if y < e.Height-1 {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (e *LogoEngine) startAssemblePhase(now time.Time) {
|
||||||
|
e.Phase = logoPhaseAssemble
|
||||||
|
e.PhaseTime = 0
|
||||||
|
e.SpawnAccumulator = 0
|
||||||
|
e.TargetNodeQueue = nil
|
||||||
|
|
||||||
|
for _, n := range e.Nodes {
|
||||||
|
if !n.IsOutline {
|
||||||
|
n.setState(logoStateIdle, now)
|
||||||
|
e.TargetNodeQueue = append(e.TargetNodeQueue, n.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rand.Shuffle(len(e.TargetNodeQueue), func(i, j int) {
|
||||||
|
e.TargetNodeQueue[i], e.TargetNodeQueue[j] = e.TargetNodeQueue[j], e.TargetNodeQueue[i]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update переключает кадр — нулевой CPU.
|
||||||
|
func (e *LogoEngine) Update(now time.Time) {
|
||||||
|
if len(e.frames) > 0 {
|
||||||
|
e.frameIndex = (e.frameIndex + 1) % len(e.frames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *LogoEngine) spawnDownloadParticleTo(target *logoNode) {
|
||||||
|
cx := float64(e.Width) / 2.0
|
||||||
|
cy := float64(e.Height) / 2.0
|
||||||
|
radius := 18.0 + rand.Float64()*5.0
|
||||||
|
angle := rand.Float64() * math.Pi * 2
|
||||||
|
|
||||||
|
startX := cx + math.Cos(angle)*radius
|
||||||
|
startY := cy + math.Sin(angle)*radius
|
||||||
|
|
||||||
|
e.Particles = append(e.Particles, NewLogoParticle(logoTypeDownload, startX, startY, target.X, target.Y, target.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *LogoEngine) spawnUploadParticle(n *logoNode) {
|
||||||
|
var targetX, targetY float64
|
||||||
|
angle := rand.Float64() * math.Pi * 2
|
||||||
|
dist := 8.0 + rand.Float64()*4.0
|
||||||
|
targetX = n.X + math.Cos(angle)*dist
|
||||||
|
targetY = n.Y + math.Sin(angle)*dist
|
||||||
|
|
||||||
|
e.Particles = append(e.Particles, NewLogoParticle(logoTypeUpload, n.X, n.Y, targetX, targetY, -1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render возвращает pre-baked кадр — нулевой CPU.
|
||||||
|
func (e *LogoEngine) Render(now time.Time) string {
|
||||||
|
if len(e.frames) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return e.frames[e.frameIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateZNodes генерирует координаты для Z-образного логотипа.
|
||||||
|
func generateZNodes(offsetX, offsetY float64) []*logoNode {
|
||||||
|
var nodes []*logoNode
|
||||||
|
id := 0
|
||||||
|
|
||||||
|
startX, endX := 0.0, 20.0
|
||||||
|
startY, endY := 0.0, 10.0
|
||||||
|
|
||||||
|
addNode := func(x, y float64) {
|
||||||
|
nodes = append(nodes, NewLogoNode(id, x+offsetX, y+offsetY))
|
||||||
|
id++
|
||||||
|
}
|
||||||
|
|
||||||
|
thicknessY := 2.0
|
||||||
|
thicknessX := 3.0
|
||||||
|
|
||||||
|
// Верхняя полоса
|
||||||
|
for y := startY; y <= startY+thicknessY; y += 1.0 {
|
||||||
|
for x := startX; x <= endX; x += 0.7 {
|
||||||
|
addNode(x, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Диагональ
|
||||||
|
steps := 50
|
||||||
|
for i := 0; i <= steps; i++ {
|
||||||
|
progress := float64(i) / float64(steps)
|
||||||
|
baseX := endX - progress*(endX-startX)
|
||||||
|
baseY := startY + progress*(endY-startY)
|
||||||
|
|
||||||
|
for ox := -thicknessX; ox <= thicknessX; ox += 0.7 {
|
||||||
|
addNode(baseX+ox, baseY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нижняя полоса
|
||||||
|
for y := endY - thicknessY; y <= endY; y += 1.0 {
|
||||||
|
for x := startX; x <= endX; x += 0.7 {
|
||||||
|
addNode(x, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var uniqueNodes []*logoNode
|
||||||
|
for _, n := range nodes {
|
||||||
|
cx, cy := int(n.X+0.5), int(n.Y+0.5)
|
||||||
|
key := fmt.Sprintf("%d,%d", cx, cy)
|
||||||
|
if !seen[key] {
|
||||||
|
seen[key] = true
|
||||||
|
uniqueNodes = append(uniqueNodes, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nodes = uniqueNodes
|
||||||
|
|
||||||
|
for idx, n := range nodes {
|
||||||
|
n.ID = idx
|
||||||
|
}
|
||||||
|
|
||||||
|
grid := make(map[string]bool)
|
||||||
|
for _, n := range nodes {
|
||||||
|
cx, cy := int(n.X+0.5), int(n.Y+0.5)
|
||||||
|
grid[fmt.Sprintf("%d,%d", cx, cy)] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, n := range nodes {
|
||||||
|
cx, cy := int(n.X+0.5), int(n.Y+0.5)
|
||||||
|
isEdge := false
|
||||||
|
for dy := -1; dy <= 1; dy++ {
|
||||||
|
for dx := -1; dx <= 1; dx++ {
|
||||||
|
if dx == 0 && dy == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
neighborKey := fmt.Sprintf("%d,%d", cx+dx, cy+dy)
|
||||||
|
if !grid[neighborKey] {
|
||||||
|
isEdge = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isEdge {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isEdge {
|
||||||
|
n.IsOutline = true
|
||||||
|
n.State = logoStateOutline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
175
ui/matrix.go
Normal file
175
ui/matrix.go
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
type matrixDrop struct {
|
||||||
|
X int
|
||||||
|
Y float64
|
||||||
|
Speed float64
|
||||||
|
Length int
|
||||||
|
CharType int // 0 = Z, 1 = V, 2 = O
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatrixEngine рендерит кадры заранее и хранит их как 2D-сетки ячеек.
|
||||||
|
// RenderRegion вырезает любой прямоугольник напрямую — без ANSI-парсинга.
|
||||||
|
type MatrixEngine struct {
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
|
||||||
|
// frames[f] — плоский массив len=Width*Height, доступ: cell = frames[f][y*Width+x]
|
||||||
|
frames [][]string
|
||||||
|
frameIndex int
|
||||||
|
}
|
||||||
|
|
||||||
|
const matrixNumFrames = 120
|
||||||
|
|
||||||
|
func NewMatrixEngine(width, height int) *MatrixEngine {
|
||||||
|
e := &MatrixEngine{
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
}
|
||||||
|
e.bakeFrames()
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MatrixEngine) bakeFrames() {
|
||||||
|
drops := []*matrixDrop{}
|
||||||
|
|
||||||
|
step := func(dt float64) {
|
||||||
|
for _, d := range drops {
|
||||||
|
d.Y += d.Speed * dt
|
||||||
|
}
|
||||||
|
var active []*matrixDrop
|
||||||
|
for _, d := range drops {
|
||||||
|
if int(d.Y)-d.Length < e.Height {
|
||||||
|
active = append(active, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drops = active
|
||||||
|
targetDrops := e.Width / 6
|
||||||
|
if targetDrops < 1 {
|
||||||
|
targetDrops = 1
|
||||||
|
}
|
||||||
|
if len(drops) < targetDrops && rand.Float64() < 0.15 {
|
||||||
|
drops = append(drops, &matrixDrop{
|
||||||
|
X: rand.Intn(e.Width),
|
||||||
|
Y: float64(rand.Intn(5)) - 5.0,
|
||||||
|
Speed: 3.0 + rand.Float64()*4.0,
|
||||||
|
Length: 4 + rand.Intn(5),
|
||||||
|
CharType: rand.Intn(3),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderGrid := func() []string {
|
||||||
|
grid := make([]string, e.Width*e.Height)
|
||||||
|
for i := range grid {
|
||||||
|
grid[i] = " "
|
||||||
|
}
|
||||||
|
for _, d := range drops {
|
||||||
|
headY := int(d.Y)
|
||||||
|
for i := 0; i < d.Length; i++ {
|
||||||
|
y := headY - i
|
||||||
|
x := d.X
|
||||||
|
if y >= 0 && y < e.Height && x >= 0 && x < e.Width {
|
||||||
|
opacity := 1.0 - float64(i)/float64(d.Length)
|
||||||
|
var r, g, b int
|
||||||
|
if i == 0 {
|
||||||
|
r, g, b = 255, 255, 255
|
||||||
|
} else if d.CharType == 0 {
|
||||||
|
g = int(255.0 * opacity)
|
||||||
|
} else if d.CharType == 1 {
|
||||||
|
r = int(255.0 * opacity)
|
||||||
|
} else {
|
||||||
|
r = int(255.0 * opacity)
|
||||||
|
g = int(255.0 * opacity)
|
||||||
|
}
|
||||||
|
char := "Z"
|
||||||
|
if d.CharType == 1 {
|
||||||
|
char = "V"
|
||||||
|
} else if d.CharType == 2 {
|
||||||
|
char = "O"
|
||||||
|
}
|
||||||
|
grid[y*e.Width+x] = getMatrixStyledChar(char, r, g, b, i == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return grid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Прогрев: заполняем экран каплями
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
step(0.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.frames = make([][]string, matrixNumFrames)
|
||||||
|
for f := 0; f < matrixNumFrames; f++ {
|
||||||
|
// 5 шагов симуляции на кадр (100ms / 5 = 20ms шаг)
|
||||||
|
for s := 0; s < 5; s++ {
|
||||||
|
step(0.02)
|
||||||
|
}
|
||||||
|
e.frames[f] = renderGrid()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update переключает кадр — нулевой CPU.
|
||||||
|
func (e *MatrixEngine) Update() {
|
||||||
|
e.frameIndex = (e.frameIndex + 1) % len(e.frames)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderRegion вырезает прямоугольник [rx,ry,rw,rh] из текущего кадра.
|
||||||
|
// Работает напрямую с ячейками — нет ANSI-парсинга, нет аллокаций.
|
||||||
|
func (e *MatrixEngine) RenderRegion(rx, ry, rw, rh int) string {
|
||||||
|
if len(e.frames) == 0 || rw <= 0 || rh <= 0 {
|
||||||
|
return strings.Repeat(" \n", rh)
|
||||||
|
}
|
||||||
|
frame := e.frames[e.frameIndex]
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.Grow(rh * rw * 8)
|
||||||
|
for row := 0; row < rh; row++ {
|
||||||
|
y := ry + row
|
||||||
|
for col := 0; col < rw; col++ {
|
||||||
|
x := rx + col
|
||||||
|
if y >= 0 && y < e.Height && x >= 0 && x < e.Width {
|
||||||
|
sb.WriteString(frame[y*e.Width+x])
|
||||||
|
} else {
|
||||||
|
sb.WriteString(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if row < rh-1 {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentFrame — весь кадр целиком (для совместимости).
|
||||||
|
func (e *MatrixEngine) CurrentFrame() string {
|
||||||
|
return e.RenderRegion(0, 0, e.Width, e.Height)
|
||||||
|
}
|
||||||
|
|
||||||
|
var matrixStyleCache = make(map[string]string)
|
||||||
|
|
||||||
|
func getMatrixStyledChar(char string, r, g, b int, bold bool) string {
|
||||||
|
hex := fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
||||||
|
key := char + hex
|
||||||
|
if bold {
|
||||||
|
key += "B"
|
||||||
|
}
|
||||||
|
if val, ok := matrixStyleCache[key]; ok {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
style := lipgloss.NewStyle().Foreground(lipgloss.Color(hex))
|
||||||
|
if bold {
|
||||||
|
style = style.Bold(true)
|
||||||
|
}
|
||||||
|
res := style.Render(char)
|
||||||
|
matrixStyleCache[key] = res
|
||||||
|
return res
|
||||||
|
}
|
||||||
265
ui/styles.go
Normal file
265
ui/styles.go
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Color definitions (Hex palette)
|
||||||
|
const (
|
||||||
|
ColorBg = "#0b1017" // Dark slate background
|
||||||
|
ColorCardBg = "#121a24" // Dark grey cards
|
||||||
|
ColorBorder = "#2e3b4e" // Dark slate borders
|
||||||
|
ColorText = "#e0ecf6" // Light grey text
|
||||||
|
ColorSubdued = "#8c9ba5" // Subdued slate text
|
||||||
|
ColorCyan = "#00ffd2" // Bright teal/cyan accent
|
||||||
|
ColorPink = "#ff2a85" // Hot pink accent
|
||||||
|
ColorGreen = "#3cd58c" // Vibrant green (completed)
|
||||||
|
ColorYellow = "#f4b942" // Amber/yellow (downloading)
|
||||||
|
ColorRed = "#ff5555" // Bright red (error/missing)
|
||||||
|
ColorBlue = "#00aeff" // Vibrant blue (dht)
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Base styles
|
||||||
|
StyleBg = lipgloss.NewStyle().
|
||||||
|
Background(lipgloss.Color(ColorBg)).
|
||||||
|
Foreground(lipgloss.Color(ColorText))
|
||||||
|
|
||||||
|
StyleTitle = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(ColorCyan)).
|
||||||
|
Border(lipgloss.DoubleBorder(), true).
|
||||||
|
BorderForeground(lipgloss.Color(ColorCyan)).
|
||||||
|
Padding(0, 2).
|
||||||
|
MarginBottom(1)
|
||||||
|
|
||||||
|
StyleHeader = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(ColorPink)).
|
||||||
|
MarginBottom(1)
|
||||||
|
|
||||||
|
// Status states
|
||||||
|
StyleStatusIdle = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(ColorSubdued))
|
||||||
|
|
||||||
|
StyleStatusActive = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(ColorYellow))
|
||||||
|
|
||||||
|
StyleStatusDone = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(ColorGreen))
|
||||||
|
|
||||||
|
StyleStatusError = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(ColorRed))
|
||||||
|
|
||||||
|
// KPI Cards
|
||||||
|
StyleCard = lipgloss.NewStyle().
|
||||||
|
Background(lipgloss.Color(ColorCardBg)).
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color(ColorBorder)).
|
||||||
|
Padding(0, 1).
|
||||||
|
Align(lipgloss.Left)
|
||||||
|
|
||||||
|
StyleCardActive = StyleCard.
|
||||||
|
BorderForeground(lipgloss.Color(ColorCyan))
|
||||||
|
|
||||||
|
StyleCardTitle = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(ColorSubdued))
|
||||||
|
|
||||||
|
StyleCardValue = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(ColorText))
|
||||||
|
|
||||||
|
// Tabs
|
||||||
|
StyleTab = lipgloss.NewStyle().
|
||||||
|
Padding(0, 2).
|
||||||
|
Background(lipgloss.Color(ColorCardBg)).
|
||||||
|
Foreground(lipgloss.Color(ColorSubdued)).
|
||||||
|
Border(lipgloss.NormalBorder(), false, true, false, false).
|
||||||
|
BorderForeground(lipgloss.Color(ColorBorder))
|
||||||
|
|
||||||
|
StyleActiveTab = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Padding(0, 2).
|
||||||
|
Background(lipgloss.Color(ColorCardBg)).
|
||||||
|
Foreground(lipgloss.Color(ColorCyan)).
|
||||||
|
Border(lipgloss.NormalBorder(), true, true, false, true).
|
||||||
|
BorderForeground(lipgloss.Color(ColorCyan))
|
||||||
|
|
||||||
|
StyleTabsRow = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||||
|
BorderForeground(lipgloss.Color(ColorBorder)).
|
||||||
|
MarginBottom(1)
|
||||||
|
|
||||||
|
// Input form
|
||||||
|
StyleInputLabel = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(ColorCyan)).
|
||||||
|
Width(15)
|
||||||
|
|
||||||
|
StyleInputFocused = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(ColorCyan)).
|
||||||
|
BorderForeground(lipgloss.Color(ColorCyan))
|
||||||
|
|
||||||
|
StyleInputUnfocused = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(ColorSubdued)).
|
||||||
|
BorderForeground(lipgloss.Color(ColorBorder))
|
||||||
|
|
||||||
|
// Help bar
|
||||||
|
StyleHelp = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(ColorSubdued)).
|
||||||
|
MarginTop(1)
|
||||||
|
|
||||||
|
StyleKey = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(ColorPink))
|
||||||
|
|
||||||
|
// Tables
|
||||||
|
StyleTableHeader = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(ColorCyan))
|
||||||
|
|
||||||
|
StyleTableRowSelected = lipgloss.NewStyle().
|
||||||
|
Background(lipgloss.Color(ColorBorder)).
|
||||||
|
Foreground(lipgloss.Color(ColorCyan)).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
// Grid colors
|
||||||
|
StylePieceCompleted = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen))
|
||||||
|
StylePieceDownloading = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorYellow))
|
||||||
|
StylePieceMissing = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorBorder))
|
||||||
|
|
||||||
|
// ── Кэшированные стили для горячих путей рендеринга ──────────────────────
|
||||||
|
|
||||||
|
// Dashboard header
|
||||||
|
StyleHeaderBrand = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Bold(true)
|
||||||
|
StyleHeaderSep = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorBorder))
|
||||||
|
StyleHeaderName = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText))
|
||||||
|
StyleHeaderIcon = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued))
|
||||||
|
StyleHeaderLine = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||||
|
BorderForeground(lipgloss.Color(ColorBorder)).
|
||||||
|
PaddingBottom(0)
|
||||||
|
|
||||||
|
// Phase status styles
|
||||||
|
StylePhaseStopped = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)).Bold(true)
|
||||||
|
StylePhaseFailed = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorRed)).Bold(true)
|
||||||
|
StylePhaseSeeding = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)).Bold(true)
|
||||||
|
StylePhaseDownload = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)).Bold(true)
|
||||||
|
StylePhaseDefault = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorYellow)).Bold(true)
|
||||||
|
|
||||||
|
// KPI cards
|
||||||
|
StyleKPIProgress = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan))
|
||||||
|
StyleKPIDown = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorGreen))
|
||||||
|
StyleKPIUp = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorYellow))
|
||||||
|
StyleKPIEta = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorPink))
|
||||||
|
StyleKPIPeers = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorText))
|
||||||
|
StyleKPIBarFill = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan))
|
||||||
|
StyleKPIBarEmpty = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorBorder))
|
||||||
|
|
||||||
|
// Menu styles
|
||||||
|
StyleMenuText = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText))
|
||||||
|
StyleMenuDesc = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued))
|
||||||
|
StyleMenuActive = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)).Bold(true)
|
||||||
|
StyleMenuCursor = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink))
|
||||||
|
StyleMenuHelp = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)).MarginTop(2)
|
||||||
|
StyleMenuBorder = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorBorder))
|
||||||
|
StyleMenuPadding = lipgloss.NewStyle().Padding(2, 4)
|
||||||
|
StyleMenuBox = lipgloss.NewStyle().Padding(2, 0)
|
||||||
|
|
||||||
|
// Log overlay styles
|
||||||
|
StyleLogTime = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued))
|
||||||
|
StyleLogEngine = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Bold(true)
|
||||||
|
StyleLogDHT = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorBlue)).Bold(true)
|
||||||
|
StyleLogPeer = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)).Bold(true)
|
||||||
|
StyleLogSys = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)).Bold(true)
|
||||||
|
StyleLogDefault = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText)).Bold(true)
|
||||||
|
StyleLogError = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorRed)).Bold(true)
|
||||||
|
StyleLogWarn = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorYellow)).Bold(true)
|
||||||
|
StyleLogInfo = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan))
|
||||||
|
StyleLogDebug = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued))
|
||||||
|
StyleLogMsg = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText))
|
||||||
|
StyleLogMsgError = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorRed))
|
||||||
|
|
||||||
|
// Overview styles
|
||||||
|
StyleSectionTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan))
|
||||||
|
StyleDim = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued))
|
||||||
|
StyleVal = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText))
|
||||||
|
StyleSpeedDown = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)).Bold(true)
|
||||||
|
StyleSpeedUp = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorYellow)).Bold(true)
|
||||||
|
StyleETA = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Bold(true)
|
||||||
|
StyleErrText = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorRed)).Bold(true)
|
||||||
|
|
||||||
|
// HelpBar
|
||||||
|
StyleHelpKey = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Bold(true)
|
||||||
|
StyleHelpDesc = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued))
|
||||||
|
StyleHelpSep = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorBorder))
|
||||||
|
StyleHelpBar = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(ColorSubdued)).
|
||||||
|
Border(lipgloss.NormalBorder(), true, false, false, false).
|
||||||
|
BorderForeground(lipgloss.Color(ColorBorder)).
|
||||||
|
MarginTop(1).
|
||||||
|
PaddingTop(0)
|
||||||
|
|
||||||
|
// Logo
|
||||||
|
StyleLogoTorrent = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText)).Bold(true)
|
||||||
|
StyleLogoZ = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)).Bold(true)
|
||||||
|
|
||||||
|
// Settings indicator
|
||||||
|
StyleSettingsCursor = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink))
|
||||||
|
StyleSettingsActive = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)).Bold(true)
|
||||||
|
StyleSettingsLabel = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)).Width(26)
|
||||||
|
|
||||||
|
// Additional menu
|
||||||
|
StyleAdditCyan = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan))
|
||||||
|
StyleAdditPink = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Bold(true)
|
||||||
|
StyleAdditVal = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText))
|
||||||
|
StyleAdditShort = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorYellow))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Статичные строки, вычисляемые один раз
|
||||||
|
var (
|
||||||
|
// Статичный "torrent" ASCII-арт (не меняется никогда)
|
||||||
|
cachedTorrentArt = StyleLogoTorrent.Render(strings.Join([]string{
|
||||||
|
"████████╗ ██████╗ ██████╗ ██████╗ ███████╗███╗ ██╗████████╗",
|
||||||
|
"╚══██╔══╝██╔═══██╗██╔══██╗██╔══██╗██╔════╝████╗ ██║╚══██╔══╝",
|
||||||
|
" ██║ ██║ ██║██████╔╝██████╔╝█████╗ ██╔██╗ ██║ ██║ ",
|
||||||
|
" ██║ ██║ ██║██╔══██╗██╔══██╗██╔══╝ ██║╚████║ ██║ ",
|
||||||
|
" ██║ ╚██████╔╝██║ ██║██║ ██║███████╗██║ ╚███║ ██║ ",
|
||||||
|
" ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚══╝ ╚═╝ ",
|
||||||
|
}, "\n"))
|
||||||
|
|
||||||
|
// Статичный Z-логотип (при отключённых анимациях)
|
||||||
|
cachedStaticZArt = StyleLogoZ.Render(strings.Join([]string{
|
||||||
|
" ",
|
||||||
|
" ",
|
||||||
|
"███████╗",
|
||||||
|
"╚════██║",
|
||||||
|
" ██╔╝",
|
||||||
|
" ██╔╝ ",
|
||||||
|
" ██╔╝ ",
|
||||||
|
" ███████╗",
|
||||||
|
" ╚══════╝",
|
||||||
|
" ",
|
||||||
|
" ",
|
||||||
|
" ",
|
||||||
|
}, "\n"))
|
||||||
|
|
||||||
|
// Статичный help bar главного меню
|
||||||
|
cachedMenuHelp = StyleMenuHelp.Render(" ↓↑ — выбор Enter — подтвердить Q — выход")
|
||||||
|
|
||||||
|
// Статичные строки рамки меню (зависят от boxWidth=85, не меняются)
|
||||||
|
cachedMenuTopBorder = StyleMenuBorder.Render("╭" + strings.Repeat("─", 85) + "╮")
|
||||||
|
cachedMenuBottomBorder = StyleMenuBorder.Render("╰" + strings.Repeat("─", 85) + "╯")
|
||||||
|
cachedMenuLeftEdge = StyleMenuBorder.Render("│")
|
||||||
|
cachedMenuRightEdge = StyleMenuBorder.Render("│")
|
||||||
|
|
||||||
|
// Help bar dashboard
|
||||||
|
cachedHelpSep = StyleHelpSep.Render(" │ ")
|
||||||
|
)
|
||||||
Loading…
Reference in a new issue