UI refactor: use overlays, fix logo, clean gitignore
This commit is contained in:
parent
e4265fa777
commit
746afb2f24
28 changed files with 6609 additions and 1039 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -1,10 +1,12 @@
|
|||
/dist/
|
||||
/downloads/
|
||||
/torrent-client
|
||||
|
||||
# Common compiled binaries
|
||||
/ztrr
|
||||
/ztorrent*
|
||||
/torrent-client*
|
||||
*.log
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.out
|
||||
.DS_Store
|
||||
|
|
|
|||
126
TODO.md
Normal file
126
TODO.md
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# ZTorrent — TODO & Контекст улучшений
|
||||
|
||||
> Цель: довести ztorrent до уровня qBittorrent по функциональности и скорости.
|
||||
> Ведётся автоматически — отражает актуальное состояние реализации.
|
||||
|
||||
---
|
||||
|
||||
## Архитектура проекта
|
||||
|
||||
```
|
||||
internal/
|
||||
torrent/
|
||||
engine.go — основной движок: загрузка торрента, воркеры, координация
|
||||
peerwire.go — BitTorrent wire protocol: handshake, download piece, messages
|
||||
piecescheduler.go — выбор кусков (rarest-first), endgame mode
|
||||
debug.go — debugf helper
|
||||
tracker/
|
||||
tracker.go — HTTP + UDP announce
|
||||
torrentfile/
|
||||
torrentfile.go — парсинг .torrent (bencode)
|
||||
app/
|
||||
controller.go — тонкая обёртка над Engine для UI
|
||||
ui/
|
||||
ui.go — Bubble Tea TUI (welcome, loading, dashboard)
|
||||
styles.go — цветовая палитра
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P0 — Критичные ✅ ГОТОВО
|
||||
|
||||
- [x] **Resume download** — `createOrOpenPartFile()` без O_TRUNC + SHA-1 верификация
|
||||
- [x] **Keep-alive** — `keepaliveLoop()` горутина, 90с, `msgCancel=8`
|
||||
- [x] **Endgame mode** — `pieceDone` broadcast + `SendCancel()` при дублировании
|
||||
|
||||
## P1 — Производительность ✅ ГОТОВО
|
||||
|
||||
- [x] **Adaptive pipeline** — depth 4–64, пересчёт каждые 4 блока по RTT+BW
|
||||
- [x] **IPv6 compact peers** — `parsePeers6()` в tracker.go
|
||||
- [x] **Rate limiting** — `rateLimitedConn`, `SetDownloadLimit`/`SetUploadLimit`
|
||||
- [x] **Bitmap resume data** — `.part.bitmap` JSON, `asyncSaveBitmap()` в heartbeat
|
||||
|
||||
## Баги ✅ ПОФИКШЕНО
|
||||
|
||||
- [x] **`announce` пустой** — BEP 12 fallback: берём первый URL из `announce-list`
|
||||
> `torrentfile.go` → `primaryAnnounce` fallback в `Open()`
|
||||
|
||||
- [x] **`peerBackoff` O(n)** → O(1) через `peerIdx map[string]int`
|
||||
> Добавлен `Engine.peerIdx`, `peerByKey()` helper
|
||||
> Все peer-методы: `setPeerState`, `setPeerError`, `peerBackoff`, `recordPieceComplete`
|
||||
|
||||
- [x] **Скорость без EWMA** → сглаживание α=0.3 в `updateSpeedLocked()`
|
||||
> `downloadSpeed = 0.3*sample + 0.7*prev`
|
||||
|
||||
- [x] **Reannounce блокирует heartbeat** → запускается в отдельной горутине
|
||||
> `heartbeatTicker.C` → `go func() { refreshPeersFromTrackers() }()`
|
||||
> Таймаут уменьшен с 20с до 15с
|
||||
|
||||
## Фича ✅ РЕАЛИЗОВАНО
|
||||
|
||||
- [x] **Incoming connections** — TCP listener на :6881
|
||||
> `listenIncoming()` + `handleIncoming()` в engine.go
|
||||
> Handshake верификация (info_hash), регистрация в `peerIdx`
|
||||
> Входящие пиры подхватываются `startWorkers` на следующем heartbeat тике
|
||||
|
||||
---
|
||||
|
||||
## P2 — Долгосрочные (не начато)
|
||||
|
||||
- [x] **DHT (BEP 5)** — Kademlia DHT для поиска пиров без трекера
|
||||
> Новый пакет `internal/dht/` — реализовано!
|
||||
|
||||
- [x] **Магнет-ссылки (BEP 9)** — `magnet:?xt=...`, ut_metadata extension
|
||||
> Новый пакет `internal/magnet/` — средняя сложность
|
||||
|
||||
- [x] **Seeding / upload (tit-for-tat)** — раздача после завершения
|
||||
> `peerwire.go` → обработка `msgRequest`, отправка `msgPiece`
|
||||
|
||||
- [x] **PEX (BEP 11)** — Peer Exchange между пирами
|
||||
> Extension handshake + `ut_pex` message
|
||||
|
||||
---
|
||||
|
||||
## Технические решения
|
||||
|
||||
### peerIdx — O(1) peer lookup
|
||||
```
|
||||
Engine.peers []PeerStatus // слайс со всеми пирами
|
||||
Engine.peerIdx map[string]int // "host:port" → индекс в слайсе
|
||||
|
||||
buildPeerIdx(peers) // строится при LoadTorrent и reannounce
|
||||
peerByKey(key) *PeerStatus // O(1) под e.mu
|
||||
```
|
||||
|
||||
### EWMA сглаживание скорости
|
||||
```
|
||||
updateSpeedLocked():
|
||||
sample = delta_bytes / elapsed_sec
|
||||
speed = 0.3*sample + 0.7*prev_speed // α=0.3
|
||||
```
|
||||
|
||||
### Incoming connections flow
|
||||
```
|
||||
runDownload()
|
||||
└── go listenIncoming(ctx, tf)
|
||||
└── net.Listen(":6881")
|
||||
└── for { conn = Accept() → go handleIncoming(conn) }
|
||||
├── ReadFull → verify wireProtocol + infoHash
|
||||
├── Write reply handshake
|
||||
└── register peer in peerIdx
|
||||
→ подхватывается startWorkers на следующем heartbeat
|
||||
```
|
||||
|
||||
### Resume flow
|
||||
```
|
||||
createOrOpenPartFile():
|
||||
├── если .part существует + .bitmap → verifyPieces() (быстро)
|
||||
├── если .part существует, нет bitmap → verifyAllPieces() (SHA-1 scan)
|
||||
└── если .part нет → создаём новый, Truncate(size)
|
||||
|
||||
heartbeat:
|
||||
asyncSaveBitmap() → .part.bitmap
|
||||
|
||||
завершение:
|
||||
os.Remove(.part) + os.Remove(.part.bitmap)
|
||||
```
|
||||
69
go.mod
69
go.mod
|
|
@ -1,41 +1,38 @@
|
|||
module github.com/veggiedefender/torrent-client
|
||||
|
||||
go 1.22
|
||||
|
||||
require github.com/jackpal/bencode-go v1.0.2
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
fyne.io/fyne/v2 v2.7.3
|
||||
fyne.io/systray v1.12.0 // indirect
|
||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/fredbi/uri v1.1.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/fyne-io/gl-js v0.2.0 // indirect
|
||||
github.com/fyne-io/glfw-js v0.3.0 // indirect
|
||||
github.com/fyne-io/image v0.1.1 // indirect
|
||||
github.com/fyne-io/oksvg v0.2.0 // indirect
|
||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
|
||||
github.com/go-text/render v0.2.0 // indirect
|
||||
github.com/go-text/typesetting v0.3.3 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
|
||||
github.com/hack-pad/safejs v0.1.0 // indirect
|
||||
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rymdport/portal v0.4.2 // indirect
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
golang.org/x/image v0.24.0 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.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/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
)
|
||||
|
|
|
|||
134
go.sum
134
go.sum
|
|
@ -1,82 +1,60 @@
|
|||
fyne.io/fyne/v2 v2.7.3 h1:xBT/iYbdnNHONWO38fZMBrVBiJG8rV/Jypmy4tVfRWE=
|
||||
fyne.io/fyne/v2 v2.7.3/go.mod h1:gu+dlIcZWSzKZmnrY8Fbnj2Hirabv2ek+AKsfQ2bBlw=
|
||||
fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
|
||||
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
|
||||
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
|
||||
github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko=
|
||||
github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs=
|
||||
github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
|
||||
github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk=
|
||||
github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
|
||||
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
|
||||
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
|
||||
github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8=
|
||||
github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
|
||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
|
||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
|
||||
github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
|
||||
github.com/go-text/typesetting v0.3.3 h1:ihGNJU9KzdK2QRDy1Bm7FT5RFQoYb+3n3EIhI/4eaQc=
|
||||
github.com/go-text/typesetting v0.3.3/go.mod h1:vIRUT25mLQaSh4C8H/lIsKppQz/Gdb8Pu/tNwpi52ts=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8 h1:4KCscI9qYWMGTuz6BpJtbUSRzcBrUSSE0ENMJbNSrFs=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
|
||||
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
|
||||
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
|
||||
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
|
||||
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
|
||||
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
|
||||
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/jackpal/bencode-go v1.0.2 h1:LcCNfZ344u0LpBPOZNjpCLps/wUOuN4r87Fy9+5yU8g=
|
||||
github.com/jackpal/bencode-go v1.0.2/go.mod h1:6jI9mUjO3GQbZti3JizEfxTzRfWOM8oBBcwbwlTfceI=
|
||||
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
|
||||
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
|
||||
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
|
||||
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
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/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=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
|
|
|
|||
|
|
@ -1,27 +1,96 @@
|
|||
package app
|
||||
|
||||
import "github.com/veggiedefender/torrent-client/internal/torrent"
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
|
||||
"github.com/veggiedefender/torrent-client/internal/history"
|
||||
"github.com/veggiedefender/torrent-client/internal/torrent"
|
||||
)
|
||||
|
||||
type Controller struct {
|
||||
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 {
|
||||
return c.engine.LoadTorrent(path, outputRoot)
|
||||
// Cancel any previous history updater
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
c.ctx, c.cancel = context.WithCancel(context.Background())
|
||||
c.currentPath = path
|
||||
c.currentOut = outputRoot
|
||||
|
||||
err := c.engine.LoadTorrent(path, outputRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.updateHistory()
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.updateHistory()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Controller) StopTorrent() {
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
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[:])
|
||||
}
|
||||
|
||||
hItem := history.Item{
|
||||
InfoHash: hashHex,
|
||||
Name: status.Name,
|
||||
TorrentPath: c.currentPath,
|
||||
OutputDir: c.currentOut,
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
389
internal/dht/dht.go
Normal file
389
internal/dht/dht.go
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
package dht
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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)
|
||||
|
||||
log.Printf("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) {
|
||||
tidBytes := make([]byte, 2)
|
||||
rand.Read(tidBytes)
|
||||
tid := string(tidBytes)
|
||||
|
||||
a["id"] = string(s.ID[:])
|
||||
msg := NewQuery(tid, q, a)
|
||||
encoded, err := EncodeMsg(msg)
|
||||
if err != nil {
|
||||
return Msg{}, err
|
||||
}
|
||||
|
||||
ch := make(chan Msg, 1)
|
||||
s.transactionsMu.Lock()
|
||||
s.transactions[tid] = ch
|
||||
s.transactionsMu.Unlock()
|
||||
|
||||
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 {
|
||||
log.Printf("DHT bootstrap %s failed: %v", name, err)
|
||||
return
|
||||
}
|
||||
if resp.R != nil {
|
||||
if nodesStr, ok := resp.R["nodes"].(string); ok {
|
||||
count := len(nodesStr) / 26
|
||||
log.Printf("DHT bootstrap %s: got %d nodes", name, count)
|
||||
s.parseAndAddNodes(nodesStr)
|
||||
}
|
||||
}
|
||||
}(addr, addrStr)
|
||||
}
|
||||
wg.Wait()
|
||||
log.Printf("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)
|
||||
}
|
||||
log.Printf("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 {
|
||||
// Нет новых нод — сбрасываем карту уже запрошенных и пробуем снова
|
||||
// (новые ноды могли добавиться в routing table)
|
||||
log.Printf("DHT: no new nodes to query (%d total queried), resetting and retrying", len(queried))
|
||||
queried = make(map[string]bool)
|
||||
// Если routing table совсем пуста — делаем повторный bootstrap
|
||||
if s.routingTable.Len() == 0 {
|
||||
go s.bootstrap(ctx)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, n := range toQuery {
|
||||
wg.Add(1)
|
||||
go func(node Node) {
|
||||
defer wg.Done()
|
||||
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 {
|
||||
log.Printf("DHT: found %d peers from %s", len(peers), node.Addr)
|
||||
select {
|
||||
case s.PeersFound <- peers:
|
||||
default:
|
||||
// канал полный — пытаемся без блокировки
|
||||
}
|
||||
}
|
||||
}
|
||||
}(n)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
108
internal/dht/routing.go
Normal file
108
internal/dht/routing.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
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[:])
|
||||
}
|
||||
|
||||
// RoutingTable manages known nodes.
|
||||
type RoutingTable struct {
|
||||
mu sync.RWMutex
|
||||
ownID NodeID
|
||||
nodes []Node
|
||||
}
|
||||
|
||||
// NewRoutingTable creates a new routing table.
|
||||
func NewRoutingTable(ownID NodeID) *RoutingTable {
|
||||
return &RoutingTable{
|
||||
ownID: ownID,
|
||||
nodes: make([]Node, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// AddNode adds a node to the routing table or updates it.
|
||||
func (rt *RoutingTable) AddNode(n Node) {
|
||||
rt.mu.Lock()
|
||||
defer rt.mu.Unlock()
|
||||
|
||||
if n.ID == rt.ownID {
|
||||
return
|
||||
}
|
||||
|
||||
for i, existing := range rt.nodes {
|
||||
if existing.ID == n.ID {
|
||||
rt.nodes[i].Addr = n.Addr
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rt.nodes = append(rt.nodes, n)
|
||||
|
||||
// Sort by distance to our own ID and keep top 1000 nodes for simplicity.
|
||||
sort.Slice(rt.nodes, func(i, j int) bool {
|
||||
distI := Distance(rt.nodes[i].ID, rt.ownID)
|
||||
distJ := Distance(rt.nodes[j].ID, rt.ownID)
|
||||
return distI.Cmp(distJ) < 0
|
||||
})
|
||||
|
||||
if len(rt.nodes) > 1000 {
|
||||
rt.nodes = rt.nodes[:1000]
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
// Since we don't have true k-buckets, we sort the entire list.
|
||||
// This is O(n log n) but fine for a small simplified table of 1000 nodes.
|
||||
sortedNodes := make([]Node, len(rt.nodes))
|
||||
copy(sortedNodes, rt.nodes)
|
||||
|
||||
sort.Slice(sortedNodes, func(i, j int) bool {
|
||||
distI := Distance(sortedNodes[i].ID, target)
|
||||
distJ := Distance(sortedNodes[j].ID, target)
|
||||
return distI.Cmp(distJ) < 0
|
||||
})
|
||||
|
||||
if len(sortedNodes) > count {
|
||||
return sortedNodes[:count]
|
||||
}
|
||||
return sortedNodes
|
||||
}
|
||||
|
||||
// Len returns the number of nodes in the routing table.
|
||||
func (rt *RoutingTable) Len() int {
|
||||
rt.mu.RLock()
|
||||
defer rt.mu.RUnlock()
|
||||
return len(rt.nodes)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
185
internal/sessionlog/sessionlog.go
Normal file
185
internal/sessionlog/sessionlog.go
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
package sessionlog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Entry представляет один лог-файл сессии.
|
||||
type Entry struct {
|
||||
Name string // имя файла без расширения
|
||||
Path string // полный путь
|
||||
ModTime time.Time // время изменения
|
||||
}
|
||||
|
||||
// Dir возвращает директорию логов (~/.ztorrent/logs).
|
||||
func Dir() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := filepath.Join(home, ".ztorrent", "logs")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// NewWriter создаёт новый md-файл для текущей сессии и возвращает:
|
||||
// - путь к файлу
|
||||
// - io.WriteCloser для записи
|
||||
// - ошибку
|
||||
func NewWriter(torrentName string) (string, io.WriteCloser, error) {
|
||||
dir, err := Dir()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
ts := time.Now().Format("2006-01-02_15-04-05")
|
||||
safe := sanitize(torrentName)
|
||||
if safe == "" {
|
||||
safe = "session"
|
||||
}
|
||||
fname := fmt.Sprintf("%s_%s.md", ts, safe)
|
||||
path := filepath.Join(dir, fname)
|
||||
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
// Записываем заголовок
|
||||
header := fmt.Sprintf("# Сессия: %s\n\n**Начало:** %s \n**Торрент:** %s\n\n---\n\n```\n",
|
||||
fname, time.Now().Format("02.01.2006 15:04:05"), torrentName)
|
||||
_, _ = f.WriteString(header)
|
||||
|
||||
return path, f, nil
|
||||
}
|
||||
|
||||
// sanitize убирает из имени файла недопустимые символы.
|
||||
func sanitize(s string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"/", "_", "\\", "_", ":", "_", "*", "_",
|
||||
"?", "_", "\"", "_", "<", "_", ">", "_", "|", "_",
|
||||
" ", "_",
|
||||
)
|
||||
s = replacer.Replace(s)
|
||||
if len(s) > 40 {
|
||||
s = s[:40]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// List возвращает список лог-файлов, отсортированных от новых к старым.
|
||||
func List() ([]Entry, error) {
|
||||
dir, err := Dir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []Entry
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, Entry{
|
||||
Name: strings.TrimSuffix(e.Name(), ".md"),
|
||||
Path: filepath.Join(dir, e.Name()),
|
||||
ModTime: info.ModTime(),
|
||||
})
|
||||
}
|
||||
|
||||
// Сортировка от новых к старым
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].ModTime.After(result[j].ModTime)
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Read читает содержимое лог-файла.
|
||||
func Read(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// Delete удаляет лог-файл.
|
||||
func Delete(path string) error {
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
// LogWriter оборачивает os.File и при записи:
|
||||
// - пишет в файл
|
||||
// - пишет в стандартный log (если enable)
|
||||
type LogWriter struct {
|
||||
file *os.File
|
||||
path string
|
||||
closed bool
|
||||
}
|
||||
|
||||
// NewLogWriter создаёт LogWriter, настраивает стандартный log на запись в файл.
|
||||
func NewLogWriter(torrentName string) (*LogWriter, error) {
|
||||
dir, err := Dir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ts := time.Now().Format("2006-01-02_15-04-05")
|
||||
safe := sanitize(torrentName)
|
||||
if safe == "" {
|
||||
safe = "session"
|
||||
}
|
||||
fname := fmt.Sprintf("%s_%s.md", ts, safe)
|
||||
path := filepath.Join(dir, fname)
|
||||
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
header := fmt.Sprintf("# Сессия: %s\n\n**Начало:** %s \n**Торрент:** %s\n\n---\n\n```\n",
|
||||
fname, time.Now().Format("02.01.2006 15:04:05"), torrentName)
|
||||
_, _ = f.WriteString(header)
|
||||
|
||||
lw := &LogWriter{file: f, path: path}
|
||||
log.SetOutput(lw)
|
||||
return lw, nil
|
||||
}
|
||||
|
||||
// Write реализует io.Writer.
|
||||
func (lw *LogWriter) Write(p []byte) (n int, err error) {
|
||||
if lw.file == nil {
|
||||
return len(p), nil
|
||||
}
|
||||
return lw.file.Write(p)
|
||||
}
|
||||
|
||||
// Path возвращает путь к файлу лога.
|
||||
func (lw *LogWriter) Path() string {
|
||||
return lw.path
|
||||
}
|
||||
|
||||
// Close закрывает файл и дописывает завершающий блок.
|
||||
func (lw *LogWriter) Close() error {
|
||||
if lw.closed || lw.file == nil {
|
||||
return nil
|
||||
}
|
||||
lw.closed = true
|
||||
_, _ = lw.file.WriteString("```\n\n---\n\n*Сессия завершена: " + time.Now().Format("02.01.2006 15:04:05") + "*\n")
|
||||
return lw.file.Close()
|
||||
}
|
||||
82
internal/storage/reader.go
Normal file
82
internal/storage/reader.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = f.ReadAt(buf[bytesRead:bytesRead+readLength], int64(fileOffset))
|
||||
f.Close()
|
||||
if err != nil && err != io.EOF {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bytesRead += readLength
|
||||
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")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -9,9 +9,15 @@ import (
|
|||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/jackpal/bencode-go"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"github.com/veggiedefender/torrent-client/internal/torrentfile"
|
||||
"github.com/veggiedefender/torrent-client/internal/tracker"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -25,108 +31,414 @@ const (
|
|||
msgBitfield = 5
|
||||
msgRequest = 6
|
||||
msgPiece = 7
|
||||
msgCancel = 8
|
||||
msgExtended = 20
|
||||
|
||||
maxWireMessageSize = 2 * 1024 * 1024
|
||||
requestBlockSize = 16 * 1024
|
||||
|
||||
peerConnectTimeout = 8 * time.Second
|
||||
// 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
|
||||
)
|
||||
|
||||
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 net.Conn
|
||||
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}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||
rawConn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
log.Printf("peer dial failed %s: %v", addr, err)
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("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: conn,
|
||||
conn: measured,
|
||||
rlConn: rlConn,
|
||||
have: make([]bool, pieceCount),
|
||||
peerIsChoked: true,
|
||||
kaStop: make(chan struct{}),
|
||||
}
|
||||
|
||||
if err := pc.sendHandshake(ctx, infoHash, peerID); err != nil {
|
||||
conn.Close()
|
||||
measured.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := pc.readHandshake(ctx, infoHash); err != nil {
|
||||
conn.Close()
|
||||
measured.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if pc.supportsExtensions {
|
||||
if err := pc.sendExtendedHandshake(ctx); err != nil {
|
||||
log.Printf("failed to send extended handshake to %s: %v", addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := pc.sendMessage(ctx, msgInterested, nil); err != nil {
|
||||
conn.Close()
|
||||
measured.Close()
|
||||
return nil, err
|
||||
}
|
||||
debugf("sent interested to %s", addr)
|
||||
|
||||
if err := pc.readInitialMessages(ctx); err != nil {
|
||||
log.Printf("peer %s initial message read failed: %v", addr, err)
|
||||
conn.Close()
|
||||
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 {
|
||||
log.Printf("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
|
||||
}
|
||||
|
||||
func (pc *peerClient) DownloadPiece(ctx context.Context, pieceIndex int, pieceLength int) ([]byte, error) {
|
||||
func (pc *peerClient) DownloadPiece(ctx context.Context, pieceIndex int, pieceLength int) ([]byte, pieceTransferStats, error) {
|
||||
var transfer pieceTransferStats
|
||||
if pieceLength <= 0 {
|
||||
return nil, fmt.Errorf("invalid piece length %d", pieceLength)
|
||||
return nil, transfer, fmt.Errorf("invalid piece length %d", pieceLength)
|
||||
}
|
||||
|
||||
if err := pc.waitForUnchoke(ctx); err != nil {
|
||||
return nil, err
|
||||
return nil, transfer, err
|
||||
}
|
||||
|
||||
startReadBytes, startWriteBytes := pc.conn.Snapshot()
|
||||
startedAt := time.Now()
|
||||
|
||||
piece := make([]byte, pieceLength)
|
||||
for offset := 0; offset < 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 {
|
||||
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, err
|
||||
return nil, transfer, err
|
||||
}
|
||||
pending[offset] = pendingRequest{
|
||||
length: blockLength,
|
||||
requestedAt: time.Now(),
|
||||
}
|
||||
offset += blockLength
|
||||
}
|
||||
|
||||
block, err := pc.readPieceBlock(ctx, pieceIndex, offset, blockLength)
|
||||
gotIndex, gotBegin, block, err := pc.readPieceMessage(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, transfer, err
|
||||
}
|
||||
copy(piece[offset:], block)
|
||||
offset += len(block)
|
||||
if gotIndex != pieceIndex {
|
||||
continue
|
||||
}
|
||||
|
||||
return piece, nil
|
||||
req, ok := pending[gotBegin]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if len(block) != req.length {
|
||||
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) {
|
||||
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)
|
||||
|
||||
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
|
||||
|
|
@ -187,13 +499,13 @@ func (pc *peerClient) sendRequest(ctx context.Context, pieceIndex, begin, length
|
|||
return pc.sendMessage(ctx, msgRequest, payload)
|
||||
}
|
||||
|
||||
func (pc *peerClient) readPieceBlock(ctx context.Context, pieceIndex, begin, expectedLen int) ([]byte, error) {
|
||||
func (pc *peerClient) readPieceMessage(ctx context.Context) (pieceIndex int, begin int, block []byte, err error) {
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
if err := pc.setReadDeadlineFromContext(ctx, peerReadTimeout); err != nil {
|
||||
return nil, err
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
|
||||
msg, err := readWireMessage(pc.conn)
|
||||
|
|
@ -201,7 +513,7 @@ func (pc *peerClient) readPieceBlock(ctx context.Context, pieceIndex, begin, exp
|
|||
if isTimeout(err) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
|
||||
switch msg.ID {
|
||||
|
|
@ -211,22 +523,15 @@ func (pc *peerClient) readPieceBlock(ctx context.Context, pieceIndex, begin, exp
|
|||
}
|
||||
gotIndex := int(binary.BigEndian.Uint32(msg.Payload[0:4]))
|
||||
gotBegin := int(binary.BigEndian.Uint32(msg.Payload[4:8]))
|
||||
block := msg.Payload[8:]
|
||||
if gotIndex != pieceIndex || gotBegin != begin {
|
||||
pc.consumeMessage(msg)
|
||||
data := msg.Payload[8:]
|
||||
if len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
if len(block) == 0 {
|
||||
continue
|
||||
}
|
||||
if len(block) > expectedLen {
|
||||
block = block[:expectedLen]
|
||||
}
|
||||
debugf("received piece %d offset %d block=%d", pieceIndex, begin, len(block))
|
||||
return block, nil
|
||||
debugf("received piece %d offset %d block=%d", gotIndex, gotBegin, len(data))
|
||||
return gotIndex, gotBegin, data, nil
|
||||
case msgChoke:
|
||||
pc.consumeMessage(msg)
|
||||
return nil, errors.New("peer choked")
|
||||
return 0, 0, nil, errors.New("peer choked")
|
||||
default:
|
||||
pc.consumeMessage(msg)
|
||||
}
|
||||
|
|
@ -257,7 +562,49 @@ func (pc *peerClient) consumeMessage(msg wireMessage) {
|
|||
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 {
|
||||
|
|
@ -265,6 +612,7 @@ func (pc *peerClient) sendHandshake(ctx context.Context, infoHash [20]byte, peer
|
|||
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[:])
|
||||
|
||||
|
|
@ -302,11 +650,87 @@ func (pc *peerClient) readHandshake(ctx context.Context, expectedInfoHash [20]by
|
|||
if !bytes.Equal(rest[infoHashOffset:infoHashOffset+20], expectedInfoHash[:]) {
|
||||
return errors.New("peer info_hash mismatch")
|
||||
}
|
||||
debugf("handshake complete")
|
||||
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, error) {
|
||||
if err := pc.setReadDeadlineFromContext(ctx, peerReadTimeout); err != nil {
|
||||
return wireMessage{}, 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
|
||||
|
|
@ -357,12 +781,88 @@ func pieceSizeForIndex(tf *torrentfile.TorrentFile, pieceIndex int) int {
|
|||
return 0
|
||||
}
|
||||
if pieceIndex == len(tf.PieceHashes)-1 {
|
||||
used := tf.PieceLength * (len(tf.PieceHashes) - 1)
|
||||
return tf.Length - used
|
||||
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, 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 {
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
msgType, ok := dict["msg_type"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pieceIdx := dict["piece"]
|
||||
|
||||
if msgType == 2 {
|
||||
return pieceIdx, nil, true, nil
|
||||
}
|
||||
if msgType == 1 {
|
||||
bytesRead := len(msg.Payload[1:]) - reader.Len()
|
||||
data := msg.Payload[1+bytesRead:]
|
||||
return pieceIdx, data, false, nil
|
||||
}
|
||||
} else if extID == 0 {
|
||||
pc.consumeMessage(msg)
|
||||
}
|
||||
} else {
|
||||
pc.consumeMessage(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -386,3 +886,10 @@ func isTimeout(err error) bool {
|
|||
var netErr net.Error
|
||||
return errors.As(err, &netErr) && netErr.Timeout()
|
||||
}
|
||||
|
||||
func maxInt64(a, b int64) int64 {
|
||||
if a >= b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ type pieceTask struct {
|
|||
}
|
||||
|
||||
type assignPieceRequest struct {
|
||||
peerID string
|
||||
have []bool
|
||||
hasInfo bool
|
||||
responseCh chan assignPieceResponse
|
||||
|
|
@ -26,39 +27,93 @@ type reportPieceRequest struct {
|
|||
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
|
||||
|
||||
// cancelCh рассылает сигнал отмены конкретному воркеру в endgame-режиме.
|
||||
// Ключ — peerID, значение — канал с индексом куска для отмены.
|
||||
cancelMu sync.RWMutex
|
||||
cancelSubs map[string]chan int
|
||||
}
|
||||
|
||||
type pieceState uint8
|
||||
|
||||
const (
|
||||
piecePending pieceState = iota
|
||||
pieceInProgress
|
||||
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, 128),
|
||||
doneCh: make(chan struct{}),
|
||||
stopCh: make(chan struct{}),
|
||||
cancelSubs: make(map[string]chan int),
|
||||
}
|
||||
|
||||
go ps.run(pieceCount)
|
||||
go ps.run(pieceCount, completedIndices)
|
||||
return ps
|
||||
}
|
||||
|
||||
func (ps *pieceScheduler) Acquire(ctx context.Context, have []bool, hasInfo bool) (pieceTask, bool, error) {
|
||||
// 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,
|
||||
|
|
@ -108,6 +163,18 @@ func (ps *pieceScheduler) Report(ctx context.Context, pieceIndex int, success bo
|
|||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -122,9 +189,22 @@ func (ps *pieceScheduler) Stop() {
|
|||
})
|
||||
}
|
||||
|
||||
func (ps *pieceScheduler) run(pieceCount int) {
|
||||
func (ps *pieceScheduler) run(pieceCount int, completedIndices []int) {
|
||||
states := make([]pieceState, pieceCount)
|
||||
availability := make([]int, pieceCount)
|
||||
peerAvailability := make(map[string][]bool, 256)
|
||||
// endgamePeers — множество пиров, которым назначен кусок в endgame режиме
|
||||
endgamePeers := make(map[int][]string) // pieceIndex -> []peerID
|
||||
completed := 0
|
||||
|
||||
// Восстанавливаем уже завершённые куски из resume
|
||||
for _, idx := range completedIndices {
|
||||
if idx >= 0 && idx < pieceCount {
|
||||
states[idx] = pieceDone
|
||||
completed++
|
||||
}
|
||||
}
|
||||
|
||||
finish := func() {
|
||||
close(ps.doneCh)
|
||||
close(ps.progressCh)
|
||||
|
|
@ -141,15 +221,36 @@ func (ps *pieceScheduler) run(pieceCount int) {
|
|||
return
|
||||
}
|
||||
|
||||
// Подсчёт оставшихся кусков для определения endgame
|
||||
remaining := pieceCount - completed
|
||||
endgame := remaining <= endgameThreshold
|
||||
|
||||
select {
|
||||
case <-ps.stopCh:
|
||||
finish()
|
||||
return
|
||||
case peerID := <-ps.releasePeerCh:
|
||||
releasePeerAvailability(peerAvailability, availability, peerID)
|
||||
case req := <-ps.assignCh:
|
||||
pieceIndex := selectPendingPiece(states, req.have, req.hasInfo)
|
||||
if req.peerID != "" && req.hasInfo {
|
||||
updatePeerAvailability(peerAvailability, availability, req.peerID, req.have, pieceCount)
|
||||
}
|
||||
|
||||
pieceIndex := -1
|
||||
if endgame {
|
||||
// Endgame: разрешаем брать куски в состоянии InProgress тоже
|
||||
pieceIndex = selectPieceEndgame(states, availability, req.have, req.hasInfo, endgamePeers, req.peerID)
|
||||
} else {
|
||||
pieceIndex = selectPendingPieceRarest(states, availability, req.have, req.hasInfo)
|
||||
}
|
||||
|
||||
if pieceIndex >= 0 {
|
||||
if !endgame {
|
||||
states[pieceIndex] = pieceInProgress
|
||||
debugf("scheduler assigned piece %d", pieceIndex)
|
||||
}
|
||||
// В endgame: запоминаем всех пиров, которые качают этот кусок
|
||||
endgamePeers[pieceIndex] = append(endgamePeers[pieceIndex], req.peerID)
|
||||
debugf("scheduler assigned piece %d to %s (endgame=%v)", pieceIndex, req.peerID, endgame)
|
||||
req.responseCh <- assignPieceResponse{
|
||||
task: pieceTask{Index: pieceIndex},
|
||||
ok: true,
|
||||
|
|
@ -157,6 +258,7 @@ func (ps *pieceScheduler) run(pieceCount int) {
|
|||
continue
|
||||
}
|
||||
req.responseCh <- assignPieceResponse{ok: false}
|
||||
|
||||
case req := <-ps.reportCh:
|
||||
if req.pieceIndex < 0 || req.pieceIndex >= len(states) {
|
||||
req.responseCh <- false
|
||||
|
|
@ -172,6 +274,15 @@ func (ps *pieceScheduler) run(pieceCount int) {
|
|||
states[req.pieceIndex] = pieceDone
|
||||
completed++
|
||||
debugf("scheduler report piece %d success (%d/%d)", req.pieceIndex, completed, pieceCount)
|
||||
|
||||
// Endgame: уведомить других пиров отменить этот кусок
|
||||
if peers, ok := endgamePeers[req.pieceIndex]; ok && len(peers) > 1 {
|
||||
// Находим winner — последний репортнувший (req не содержит peerID,
|
||||
// поэтому рассылаем всем — воркер проверяет индекс)
|
||||
go ps.broadcastCancel(req.pieceIndex, "")
|
||||
}
|
||||
delete(endgamePeers, req.pieceIndex)
|
||||
|
||||
select {
|
||||
case ps.progressCh <- req.pieceIndex:
|
||||
default:
|
||||
|
|
@ -180,37 +291,156 @@ func (ps *pieceScheduler) run(pieceCount int) {
|
|||
continue
|
||||
}
|
||||
|
||||
// Неуспех: возвращаем кусок в pending
|
||||
if states[req.pieceIndex] == pieceInProgress {
|
||||
states[req.pieceIndex] = piecePending
|
||||
debugf("scheduler report piece %d failed, re-queued", req.pieceIndex)
|
||||
}
|
||||
// В endgame: убираем только этого пира из списка
|
||||
if peers, ok := endgamePeers[req.pieceIndex]; ok {
|
||||
filtered := peers[:0]
|
||||
for _, p := range peers {
|
||||
if p != "" { // убираем все (peerID недоступен в req)
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
delete(endgamePeers, req.pieceIndex)
|
||||
} else {
|
||||
endgamePeers[req.pieceIndex] = filtered
|
||||
}
|
||||
}
|
||||
req.responseCh <- false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func selectPendingPiece(states []pieceState, have []bool, hasInfo bool) int {
|
||||
// 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, treat it as potentially having any piece.
|
||||
// If peer has not sent bitfield/have yet, optimistically probe.
|
||||
if !hasInfo {
|
||||
return firstPending
|
||||
}
|
||||
|
||||
// Prefer pieces explicitly advertised by peer.
|
||||
bestPiece := -1
|
||||
bestAvailability := 0
|
||||
|
||||
for pieceIndex, state := range states {
|
||||
if state != piecePending {
|
||||
continue
|
||||
}
|
||||
if pieceIndex < len(have) && have[pieceIndex] {
|
||||
return pieceIndex
|
||||
if pieceIndex >= len(have) || !have[pieceIndex] {
|
||||
continue
|
||||
}
|
||||
|
||||
count := availability[pieceIndex]
|
||||
if bestPiece == -1 || count < bestAvailability || (count == bestAvailability && pieceIndex < bestPiece) {
|
||||
bestPiece = pieceIndex
|
||||
bestAvailability = count
|
||||
}
|
||||
}
|
||||
|
||||
// Some peers send missing/truncated availability info; allow optimistic probing.
|
||||
if bestPiece >= 0 {
|
||||
return bestPiece
|
||||
}
|
||||
|
||||
// Some peers send truncated availability info; allow fallback probing.
|
||||
if len(have) == 0 || len(have) < len(states) {
|
||||
return firstPending
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,23 @@ func Open(path string) (*TorrentFile, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateMetadata(bto.Info, bto.Announce); err != nil {
|
||||
// Если 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
|
||||
}
|
||||
|
||||
|
|
@ -82,10 +98,10 @@ func Open(path string) (*TorrentFile, error) {
|
|||
}
|
||||
|
||||
infoHash := sha1.Sum(infoBytes)
|
||||
trackers := collectTrackers(bto.Announce, bto.AnnounceList)
|
||||
trackers := collectTrackers(primaryAnnounce, bto.AnnounceList)
|
||||
|
||||
tf := TorrentFile{
|
||||
Announce: bto.Announce,
|
||||
Announce: primaryAnnounce,
|
||||
Trackers: trackers,
|
||||
InfoHash: infoHash,
|
||||
PieceHashes: pieceHashes,
|
||||
|
|
@ -98,10 +114,53 @@ func Open(path string) (*TorrentFile, error) {
|
|||
return &tf, nil
|
||||
}
|
||||
|
||||
func validateMetadata(info bencodeInfo, announce string) error {
|
||||
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 announce == "":
|
||||
return errors.New("torrent announce URL is empty")
|
||||
case info.Name == "":
|
||||
return errors.New("torrent name is empty")
|
||||
case info.PieceLength <= 0:
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ type TrackerResponse struct {
|
|||
FailureReason string `bencode:"failure reason"`
|
||||
Interval int `bencode:"interval"`
|
||||
Peers string `bencode:"peers"`
|
||||
Peers6 string `bencode:"peers6"`
|
||||
}
|
||||
|
||||
type Peer struct {
|
||||
|
|
@ -45,14 +46,10 @@ func GetPeers(ctx context.Context, tf *torrentfile.TorrentFile, opts AnnounceOpt
|
|||
if tf == nil {
|
||||
return nil, errors.New("torrent metadata is nil")
|
||||
}
|
||||
return GetPeersFromURL(ctx, tf.Announce, tf, opts)
|
||||
return GetPeersFromURL(ctx, tf.Announce, tf.InfoHash, tf.Length, opts)
|
||||
}
|
||||
|
||||
func GetPeersFromURL(ctx context.Context, announce string, tf *torrentfile.TorrentFile, opts AnnounceOptions) ([]Peer, error) {
|
||||
if tf == nil {
|
||||
return nil, errors.New("torrent metadata is nil")
|
||||
}
|
||||
|
||||
func GetPeersFromURL(ctx context.Context, announce string, infoHash [20]byte, length int, opts AnnounceOptions) ([]Peer, error) {
|
||||
opts = normalizeOptions(opts)
|
||||
|
||||
announceURL, err := url.Parse(announce)
|
||||
|
|
@ -63,22 +60,22 @@ func GetPeersFromURL(ctx context.Context, announce string, tf *torrentfile.Torre
|
|||
switch announceURL.Scheme {
|
||||
case "http", "https":
|
||||
client := &http.Client{Timeout: opts.Timeout}
|
||||
return getPeersWithClient(ctx, client, announceURL, tf, opts)
|
||||
return getPeersWithClient(ctx, client, announceURL, infoHash, length, opts)
|
||||
case "udp":
|
||||
return getPeersUDP(ctx, announceURL, tf, opts)
|
||||
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, tf *torrentfile.TorrentFile, opts AnnounceOptions) ([]Peer, error) {
|
||||
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, tf, opts)
|
||||
announceURLString, err := buildAnnounceURL(announceURL, infoHash, length, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -109,10 +106,24 @@ func getPeersWithClient(ctx context.Context, client httpDoer, announceURL *url.U
|
|||
return nil, fmt.Errorf("tracker failure: %s", tr.FailureReason)
|
||||
}
|
||||
|
||||
return parsePeers([]byte(tr.Peers))
|
||||
// Парсим 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, tf *torrentfile.TorrentFile, opts AnnounceOptions) ([]Peer, error) {
|
||||
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")
|
||||
}
|
||||
|
|
@ -169,7 +180,7 @@ func getPeersUDP(ctx context.Context, announceURL *url.URL, tf *torrentfile.Torr
|
|||
return nil, err
|
||||
}
|
||||
|
||||
announceReq, err := buildUDPAnnounceRequest(connectionID, announceTx, tf, opts, key)
|
||||
announceReq, err := buildUDPAnnounceRequest(connectionID, announceTx, infoHash, length, opts, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -198,10 +209,7 @@ func normalizeOptions(opts AnnounceOptions) AnnounceOptions {
|
|||
return opts
|
||||
}
|
||||
|
||||
func buildAnnounceURL(baseURL *url.URL, tf *torrentfile.TorrentFile, opts AnnounceOptions) (string, error) {
|
||||
if tf == nil {
|
||||
return "", errors.New("torrent metadata is nil")
|
||||
}
|
||||
func buildAnnounceURL(baseURL *url.URL, infoHash [20]byte, length int, opts AnnounceOptions) (string, error) {
|
||||
if baseURL == nil {
|
||||
return "", errors.New("tracker URL is nil")
|
||||
}
|
||||
|
|
@ -222,7 +230,7 @@ func buildAnnounceURL(baseURL *url.URL, tf *torrentfile.TorrentFile, opts Announ
|
|||
downloaded = 0
|
||||
}
|
||||
|
||||
left := int64(tf.Length) - downloaded
|
||||
left := int64(length) - downloaded
|
||||
if left < 0 {
|
||||
left = 0
|
||||
}
|
||||
|
|
@ -233,7 +241,7 @@ func buildAnnounceURL(baseURL *url.URL, tf *torrentfile.TorrentFile, opts Announ
|
|||
}
|
||||
|
||||
parts = append(parts,
|
||||
"info_hash="+escapeBinary(tf.InfoHash[:]),
|
||||
"info_hash="+escapeBinary(infoHash[:]),
|
||||
"peer_id="+escapeBinary(opts.PeerID[:]),
|
||||
"port="+strconv.Itoa(int(opts.Port)),
|
||||
"uploaded="+strconv.FormatInt(uploaded, 10),
|
||||
|
|
@ -280,10 +288,7 @@ func parseUDPConnectResponse(payload []byte, expectedTransactionID uint32) (uint
|
|||
return binary.BigEndian.Uint64(payload[8:16]), nil
|
||||
}
|
||||
|
||||
func buildUDPAnnounceRequest(connectionID uint64, transactionID uint32, tf *torrentfile.TorrentFile, opts AnnounceOptions, key uint32) ([]byte, error) {
|
||||
if tf == nil {
|
||||
return nil, errors.New("torrent metadata is nil")
|
||||
}
|
||||
func buildUDPAnnounceRequest(connectionID uint64, transactionID uint32, infoHash [20]byte, length int, opts AnnounceOptions, key uint32) ([]byte, error) {
|
||||
|
||||
uploaded := opts.Uploaded
|
||||
if uploaded < 0 {
|
||||
|
|
@ -293,7 +298,7 @@ func buildUDPAnnounceRequest(connectionID uint64, transactionID uint32, tf *torr
|
|||
if downloaded < 0 {
|
||||
downloaded = 0
|
||||
}
|
||||
left := int64(tf.Length) - downloaded
|
||||
left := int64(length) - downloaded
|
||||
if left < 0 {
|
||||
left = 0
|
||||
}
|
||||
|
|
@ -302,7 +307,7 @@ func buildUDPAnnounceRequest(connectionID uint64, transactionID uint32, tf *torr
|
|||
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], tf.InfoHash[:])
|
||||
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))
|
||||
|
|
@ -337,7 +342,7 @@ func parseUDPAnnounceResponse(payload []byte, expectedTransactionID uint32) ([]P
|
|||
return nil, fmt.Errorf("udp tracker announce response too short: %d bytes", len(payload))
|
||||
}
|
||||
|
||||
return parsePeers(payload[20:])
|
||||
return ParsePeers(payload[20:])
|
||||
}
|
||||
|
||||
func parseUDPTrackerError(payload []byte) error {
|
||||
|
|
@ -378,7 +383,8 @@ func isURLUnreserved(c byte) bool {
|
|||
}
|
||||
}
|
||||
|
||||
func parsePeers(data []byte) ([]Peer, error) {
|
||||
// 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
|
||||
}
|
||||
|
|
@ -389,8 +395,8 @@ func parsePeers(data []byte) ([]Peer, error) {
|
|||
var peers []Peer
|
||||
|
||||
for i := 0; i < len(data); i += 6 {
|
||||
|
||||
ip := net.IP(data[i : i+4])
|
||||
ip := make(net.IP, 4)
|
||||
copy(ip, data[i:i+4])
|
||||
|
||||
port := uint16(data[i+4])<<8 |
|
||||
uint16(data[i+5])
|
||||
|
|
@ -404,6 +410,47 @@ func parsePeers(data []byte) ([]Peer, error) {
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ func TestParsePeers(t *testing.T) {
|
|||
t.Parallel()
|
||||
|
||||
raw := []byte{127, 0, 0, 1, 0x1A, 0xE1}
|
||||
peers, err := parsePeers(raw)
|
||||
peers, err := ParsePeers(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePeers returned error: %v", err)
|
||||
}
|
||||
|
|
@ -35,9 +35,9 @@ func TestParsePeers(t *testing.T) {
|
|||
func TestParsePeersRejectsInvalidLength(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parsePeers([]byte{1, 2, 3, 4, 5})
|
||||
_, err := ParsePeers([]byte{1, 2, 3, 4, 5})
|
||||
if err == nil {
|
||||
t.Fatal("expected parsePeers to fail for invalid compact peer data")
|
||||
t.Fatal("expected ParsePeers to fail for invalid compact peer data")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ func TestGetPeersBuildsAnnounceAndParsesResponse(t *testing.T) {
|
|||
t.Fatalf("failed to parse announce URL: %v", err)
|
||||
}
|
||||
|
||||
peers, err := getPeersWithClient(context.Background(), client, announceURL, tf, AnnounceOptions{
|
||||
peers, err := getPeersWithClient(context.Background(), client, announceURL, tf.InfoHash, tf.Length, AnnounceOptions{
|
||||
PeerID: peerID,
|
||||
Port: 6881,
|
||||
Downloaded: 100,
|
||||
|
|
@ -134,7 +134,7 @@ func TestBuildAnnounceURLEncodesBinaryValues(t *testing.T) {
|
|||
t.Fatalf("failed to parse announce URL: %v", err)
|
||||
}
|
||||
|
||||
announceURL, err := buildAnnounceURL(baseURL, tf, AnnounceOptions{
|
||||
announceURL, err := buildAnnounceURL(baseURL, tf.InfoHash, tf.Length, AnnounceOptions{
|
||||
PeerID: peerID,
|
||||
Port: 6881,
|
||||
NumWant: 42,
|
||||
|
|
@ -173,9 +173,7 @@ func TestBuildAnnounceURLRejectsUnsupportedScheme(t *testing.T) {
|
|||
t.Fatalf("failed to parse announce URL: %v", err)
|
||||
}
|
||||
|
||||
_, err = buildAnnounceURL(baseURL, &torrentfile.TorrentFile{
|
||||
Length: 10,
|
||||
}, AnnounceOptions{})
|
||||
_, err = buildAnnounceURL(baseURL, [20]byte{}, 10, AnnounceOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected buildAnnounceURL to reject unsupported tracker scheme")
|
||||
}
|
||||
|
|
@ -204,7 +202,7 @@ func TestGetPeersReturnsTrackerFailure(t *testing.T) {
|
|||
t.Fatalf("failed to parse announce URL: %v", err)
|
||||
}
|
||||
|
||||
_, err = getPeersWithClient(context.Background(), client, announceURL, tf, AnnounceOptions{})
|
||||
_, err = getPeersWithClient(context.Background(), client, announceURL, tf.InfoHash, tf.Length, AnnounceOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected getPeersWithClient to return tracker failure error")
|
||||
}
|
||||
|
|
@ -265,7 +263,7 @@ func TestBuildUDPAnnounceRequest(t *testing.T) {
|
|||
var peerID [20]byte
|
||||
copy(peerID[:], []byte("-ZT0001-123456789012"))
|
||||
|
||||
req, err := buildUDPAnnounceRequest(0x0102030405060708, 42, tf, AnnounceOptions{
|
||||
req, err := buildUDPAnnounceRequest(0x0102030405060708, 42, tf.InfoHash, tf.Length, AnnounceOptions{
|
||||
PeerID: peerID,
|
||||
Port: 6881,
|
||||
Uploaded: -1,
|
||||
|
|
|
|||
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"
|
||||
)
|
||||
|
|
@ -32,15 +32,10 @@ for target in "${TARGETS[@]}"; do
|
|||
rm -f "${output}"
|
||||
echo "Building ${output}"
|
||||
|
||||
if [[ "${goos}" == "${host_os}" ]]; then
|
||||
if ! GOCACHE="${GOCACHE}" CGO_ENABLED=1 GOOS="${goos}" GOARCH="${goarch}" go build -o "${output}" ./cmd/torrent-client; then
|
||||
echo "Failed to build local target ${goos}/${goarch}"
|
||||
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
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Skipping ${goos}/${goarch}: native GUI build requires building on that target OS/arch (or a configured cross C toolchain)."
|
||||
done
|
||||
|
||||
if [[ "${build_failed}" -ne 0 ]]; then
|
||||
|
|
|
|||
510
ui/logo.go
Normal file
510
ui/logo.go
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
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
|
||||
}
|
||||
|
||||
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)
|
||||
return e
|
||||
}
|
||||
|
||||
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]
|
||||
})
|
||||
}
|
||||
|
||||
func (e *LogoEngine) Update(now time.Time) {
|
||||
if e.LastUpdate.IsZero() {
|
||||
e.LastUpdate = now
|
||||
}
|
||||
dt := now.Sub(e.LastUpdate).Seconds()
|
||||
if dt > 0.1 {
|
||||
dt = 0.1
|
||||
}
|
||||
e.LastUpdate = now
|
||||
|
||||
e.PhaseTime += dt
|
||||
|
||||
switch e.Phase {
|
||||
case logoPhaseAssemble:
|
||||
spawnRate := float64(len(e.Nodes)) / 2.0 // Заполнение за ~2 секунды
|
||||
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 := make([]*logoParticle, 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) 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))
|
||||
}
|
||||
|
||||
func (e *LogoEngine) Render(now time.Time) string {
|
||||
grid := make([][]string, e.Height)
|
||||
for y := 0; y < e.Height; y++ {
|
||||
grid[y] = make([]string, e.Width)
|
||||
for x := 0; x < e.Width; x++ {
|
||||
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)
|
||||
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()
|
||||
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)
|
||||
grid[y][x] = style.Render(char)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for y := 0; y < e.Height; y++ {
|
||||
sb.WriteString(strings.Join(grid[y], ""))
|
||||
if y < e.Height-1 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
132
ui/styles.go
Normal file
132
ui/styles.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
package ui
|
||||
|
||||
import "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)
|
||||
)
|
||||
|
||||
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))
|
||||
)
|
||||
679
ui/window.go
679
ui/window.go
|
|
@ -1,679 +0,0 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
fyneapp "fyne.io/fyne/v2/app"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/storage"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
appcore "github.com/veggiedefender/torrent-client/internal/app"
|
||||
"github.com/veggiedefender/torrent-client/internal/torrent"
|
||||
"github.com/veggiedefender/torrent-client/internal/torrentfile"
|
||||
)
|
||||
|
||||
type desktopUI struct {
|
||||
controller *appcore.Controller
|
||||
window fyne.Window
|
||||
|
||||
pathEntry *widget.Entry
|
||||
downloadDirEntry *widget.Entry
|
||||
|
||||
browseButton *widget.Button
|
||||
browseOutputButton *widget.Button
|
||||
startButton *widget.Button
|
||||
stopButton *widget.Button
|
||||
|
||||
progress *widget.ProgressBar
|
||||
phaseLabel *widget.Label
|
||||
phaseMetricLabel *widget.Label
|
||||
statusSummaryLabel *widget.Label
|
||||
messageLabel *widget.Label
|
||||
errorLabel *widget.Label
|
||||
nameLabel *widget.Label
|
||||
sizeLabel *widget.Label
|
||||
pieceLabel *widget.Label
|
||||
pieceCountLabel *widget.Label
|
||||
downloadedLabel *widget.Label
|
||||
outputLabel *widget.Label
|
||||
speedLabel *widget.Label
|
||||
peerCountLabel *widget.Label
|
||||
|
||||
mu sync.RWMutex
|
||||
trackers []torrent.TrackerStatus
|
||||
peers []torrent.PeerStatus
|
||||
files []torrentfile.File
|
||||
inputError string
|
||||
loading bool
|
||||
|
||||
trackersTable *widget.Table
|
||||
peersTable *widget.Table
|
||||
filesTable *widget.Table
|
||||
|
||||
stopRefresh chan struct{}
|
||||
}
|
||||
|
||||
func Start(controller *appcore.Controller) {
|
||||
desktop := newDesktopUI(controller)
|
||||
desktop.window.ShowAndRun()
|
||||
}
|
||||
|
||||
func newDesktopUI(controller *appcore.Controller) *desktopUI {
|
||||
a := fyneapp.NewWithID("github.com/veggiedefender/ztorrent")
|
||||
w := a.NewWindow("Ztorrent Desktop")
|
||||
w.Resize(fyne.NewSize(1280, 800))
|
||||
|
||||
ui := &desktopUI{
|
||||
controller: controller,
|
||||
window: w,
|
||||
stopRefresh: make(chan struct{}),
|
||||
pathEntry: widget.NewEntry(),
|
||||
downloadDirEntry: widget.NewEntry(),
|
||||
progress: widget.NewProgressBar(),
|
||||
phaseLabel: widget.NewLabel("Phase: Idle"),
|
||||
phaseMetricLabel: widget.NewLabel("Idle"),
|
||||
statusSummaryLabel: widget.NewLabel("0.0% | 0 B / - | 0 B/s"),
|
||||
messageLabel: widget.NewLabel("Ready"),
|
||||
errorLabel: widget.NewLabel(""),
|
||||
nameLabel: widget.NewLabel("-"),
|
||||
sizeLabel: widget.NewLabel("-"),
|
||||
pieceLabel: widget.NewLabel("-"),
|
||||
pieceCountLabel: widget.NewLabel("-"),
|
||||
downloadedLabel: widget.NewLabel("0 B / -"),
|
||||
outputLabel: widget.NewLabel("-"),
|
||||
speedLabel: widget.NewLabel("0 B/s"),
|
||||
peerCountLabel: widget.NewLabel("0"),
|
||||
}
|
||||
|
||||
ui.pathEntry.SetPlaceHolder("/path/to/file.torrent")
|
||||
ui.downloadDirEntry.SetPlaceHolder("/path/to/download/folder")
|
||||
ui.messageLabel.Wrapping = fyne.TextWrapWord
|
||||
ui.errorLabel.Wrapping = fyne.TextWrapWord
|
||||
ui.outputLabel.Wrapping = fyne.TextWrapWord
|
||||
ui.nameLabel.Wrapping = fyne.TextWrapWord
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
ui.downloadDirEntry.SetText(home)
|
||||
}
|
||||
|
||||
ui.browseButton = widget.NewButton("Browse Torrent", ui.onBrowse)
|
||||
ui.browseOutputButton = widget.NewButton("Browse Folder", ui.onBrowseOutputFolder)
|
||||
ui.startButton = widget.NewButton("Start Download", ui.onLoad)
|
||||
ui.stopButton = widget.NewButton("Stop", ui.onStop)
|
||||
ui.stopButton.Disable()
|
||||
|
||||
torrentRow := container.NewBorder(nil, nil, nil, ui.browseButton, ui.pathEntry)
|
||||
actions := container.NewHBox(ui.browseOutputButton, ui.startButton, ui.stopButton)
|
||||
outputRow := container.NewBorder(nil, nil, nil, actions, ui.downloadDirEntry)
|
||||
inputCard := widget.NewCard(
|
||||
"Input",
|
||||
"Choose .torrent source and download output folder",
|
||||
container.NewVBox(torrentRow, outputRow),
|
||||
)
|
||||
|
||||
metaGrid := container.NewGridWithColumns(3,
|
||||
wrapMetric("Name", ui.nameLabel),
|
||||
wrapMetric("Size", ui.sizeLabel),
|
||||
wrapMetric("Piece Length", ui.pieceLabel),
|
||||
wrapMetric("Completed Pieces", ui.pieceCountLabel),
|
||||
wrapMetric("Transferred", ui.downloadedLabel),
|
||||
wrapMetric("Speed", ui.speedLabel),
|
||||
wrapMetric("Output", ui.outputLabel),
|
||||
wrapMetric("Discovered Peers", ui.peerCountLabel),
|
||||
wrapMetric("Phase", ui.phaseMetricLabel),
|
||||
)
|
||||
detailsCard := widget.NewCard("Details", "", metaGrid)
|
||||
|
||||
ui.trackersTable = ui.newTrackersTable()
|
||||
ui.peersTable = ui.newPeersTable()
|
||||
ui.filesTable = ui.newFilesTable()
|
||||
|
||||
tabs := container.NewAppTabs(
|
||||
container.NewTabItem("Trackers", container.NewMax(ui.trackersTable)),
|
||||
container.NewTabItem("Peers", container.NewMax(ui.peersTable)),
|
||||
container.NewTabItem("Files", container.NewMax(ui.filesTable)),
|
||||
)
|
||||
tabs.SetTabLocation(container.TabLocationTop)
|
||||
|
||||
statusColumn := container.NewVBox(
|
||||
ui.phaseLabel,
|
||||
ui.progress,
|
||||
ui.statusSummaryLabel,
|
||||
ui.messageLabel,
|
||||
ui.errorLabel,
|
||||
)
|
||||
statusCard := widget.NewCard("Transfer Status", "", statusColumn)
|
||||
|
||||
header := container.NewVBox(inputCard, statusCard, detailsCard)
|
||||
content := container.NewBorder(header, nil, nil, nil, tabs)
|
||||
|
||||
w.SetContent(content)
|
||||
w.SetOnClosed(func() {
|
||||
close(ui.stopRefresh)
|
||||
ui.controller.StopTorrent()
|
||||
})
|
||||
|
||||
ui.updateFromStatus(ui.controller.Status())
|
||||
|
||||
if len(os.Args) > 1 {
|
||||
ui.pathEntry.SetText(os.Args[1])
|
||||
ui.onLoad()
|
||||
}
|
||||
|
||||
go ui.refreshLoop()
|
||||
return ui
|
||||
}
|
||||
|
||||
func wrapMetric(name string, value fyne.CanvasObject) fyne.CanvasObject {
|
||||
return container.NewVBox(widget.NewLabel(name), value)
|
||||
}
|
||||
|
||||
func (ui *desktopUI) onBrowse() {
|
||||
filter := storage.NewExtensionFileFilter([]string{".torrent"})
|
||||
fd := dialog.NewFileOpen(func(reader fyne.URIReadCloser, err error) {
|
||||
if err != nil {
|
||||
ui.setError(err.Error())
|
||||
return
|
||||
}
|
||||
if reader == nil {
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
ui.pathEntry.SetText(reader.URI().Path())
|
||||
ui.setError("")
|
||||
}, ui.window)
|
||||
fd.SetFilter(filter)
|
||||
fd.Show()
|
||||
}
|
||||
|
||||
func (ui *desktopUI) onLoad() {
|
||||
path, downloadRoot, err := ui.validateInputs()
|
||||
if err != nil {
|
||||
ui.setError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ui.mu.Lock()
|
||||
if ui.loading {
|
||||
ui.mu.Unlock()
|
||||
return
|
||||
}
|
||||
ui.loading = true
|
||||
ui.inputError = ""
|
||||
ui.mu.Unlock()
|
||||
|
||||
ui.setMessage("Loading metadata and contacting trackers...")
|
||||
ui.setError("")
|
||||
ui.applyControlState("loading_metadata")
|
||||
|
||||
go func(path, outputRoot string) {
|
||||
err := ui.controller.StartTorrent(path, outputRoot)
|
||||
status := ui.controller.Status()
|
||||
|
||||
fyne.Do(func() {
|
||||
ui.mu.Lock()
|
||||
ui.loading = false
|
||||
ui.mu.Unlock()
|
||||
|
||||
ui.updateFromStatusOnUI(status)
|
||||
if err != nil {
|
||||
ui.setMessageOnUI("Failed to load " + filepath.Base(path))
|
||||
ui.setErrorOnUI(err.Error())
|
||||
return
|
||||
}
|
||||
ui.setMessageOnUI("Loaded " + filepath.Base(path))
|
||||
})
|
||||
}(path, downloadRoot)
|
||||
}
|
||||
|
||||
func (ui *desktopUI) onStop() {
|
||||
ui.mu.Lock()
|
||||
ui.loading = false
|
||||
ui.mu.Unlock()
|
||||
|
||||
ui.setMessage("Stopping...")
|
||||
ui.setError("")
|
||||
|
||||
go func() {
|
||||
ui.controller.StopTorrent()
|
||||
status := ui.controller.Status()
|
||||
|
||||
fyne.Do(func() {
|
||||
ui.updateFromStatusOnUI(status)
|
||||
if status.Phase == "stopped" || status.Phase == "idle" {
|
||||
ui.setMessageOnUI("Stopped")
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
func (ui *desktopUI) validateInputs() (string, string, error) {
|
||||
pathRaw := strings.TrimSpace(ui.pathEntry.Text)
|
||||
if pathRaw == "" {
|
||||
return "", "", fmt.Errorf("select a .torrent file first")
|
||||
}
|
||||
path := filepath.Clean(pathRaw)
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("torrent file is not accessible: %w", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return "", "", fmt.Errorf("torrent path must point to a file")
|
||||
}
|
||||
if ext := strings.ToLower(filepath.Ext(path)); ext != ".torrent" {
|
||||
return "", "", fmt.Errorf("selected file must have .torrent extension")
|
||||
}
|
||||
|
||||
downloadRootRaw := strings.TrimSpace(ui.downloadDirEntry.Text)
|
||||
if downloadRootRaw == "" {
|
||||
return "", "", fmt.Errorf("select a download folder first")
|
||||
}
|
||||
downloadRoot := filepath.Clean(downloadRootRaw)
|
||||
|
||||
dirInfo, err := os.Stat(downloadRoot)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("download folder is not accessible: %w", err)
|
||||
}
|
||||
if !dirInfo.IsDir() {
|
||||
return "", "", fmt.Errorf("download path must be a folder")
|
||||
}
|
||||
|
||||
return path, downloadRoot, nil
|
||||
}
|
||||
|
||||
func (ui *desktopUI) onBrowseOutputFolder() {
|
||||
fd := dialog.NewFolderOpen(func(uri fyne.ListableURI, err error) {
|
||||
if err != nil {
|
||||
ui.setError(err.Error())
|
||||
return
|
||||
}
|
||||
if uri == nil {
|
||||
return
|
||||
}
|
||||
ui.downloadDirEntry.SetText(uri.Path())
|
||||
ui.setError("")
|
||||
}, ui.window)
|
||||
fd.Show()
|
||||
}
|
||||
|
||||
func (ui *desktopUI) refreshLoop() {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ui.stopRefresh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
ui.updateFromStatus(ui.controller.Status())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ui *desktopUI) updateFromStatus(status torrent.Status) {
|
||||
fyne.Do(func() {
|
||||
ui.updateFromStatusOnUI(status)
|
||||
})
|
||||
}
|
||||
|
||||
func (ui *desktopUI) updateFromStatusOnUI(status torrent.Status) {
|
||||
progress := clampProgress(status.Progress)
|
||||
phaseText := formatPhase(status.Phase)
|
||||
|
||||
ui.progress.SetValue(progress)
|
||||
ui.phaseLabel.SetText("Phase: " + phaseText)
|
||||
ui.phaseMetricLabel.SetText(phaseText)
|
||||
ui.statusSummaryLabel.SetText(formatProgressSummary(status))
|
||||
|
||||
if status.Name == "" {
|
||||
ui.nameLabel.SetText("-")
|
||||
} else {
|
||||
ui.nameLabel.SetText(status.Name)
|
||||
}
|
||||
|
||||
if status.Length > 0 {
|
||||
ui.sizeLabel.SetText(formatBytes(int64(status.Length)))
|
||||
} else {
|
||||
ui.sizeLabel.SetText("-")
|
||||
}
|
||||
|
||||
if status.PieceLength > 0 {
|
||||
ui.pieceLabel.SetText(formatBytes(int64(status.PieceLength)))
|
||||
} else {
|
||||
ui.pieceLabel.SetText("-")
|
||||
}
|
||||
|
||||
if status.PieceCount > 0 {
|
||||
pieceProgress := float64(status.CompletedPieces) / float64(status.PieceCount)
|
||||
ui.pieceCountLabel.SetText(fmt.Sprintf("%d / %d (%s)", status.CompletedPieces, status.PieceCount, formatPercent(pieceProgress)))
|
||||
} else {
|
||||
ui.pieceCountLabel.SetText("-")
|
||||
}
|
||||
|
||||
ui.downloadedLabel.SetText(formatTransferred(status.DownloadedBytes, status.TotalBytes))
|
||||
ui.speedLabel.SetText(formatBytesRate(status.DownloadSpeed))
|
||||
if status.OutputPath == "" {
|
||||
ui.outputLabel.SetText("-")
|
||||
} else {
|
||||
ui.outputLabel.SetText(status.OutputPath)
|
||||
}
|
||||
ui.peerCountLabel.SetText(strconv.Itoa(status.PeerCount))
|
||||
|
||||
ui.mu.Lock()
|
||||
ui.trackers = append(ui.trackers[:0], status.Trackers...)
|
||||
ui.peers = append(ui.peers[:0], status.Peers...)
|
||||
ui.files = append(ui.files[:0], status.Files...)
|
||||
manualErr := ui.inputError
|
||||
ui.mu.Unlock()
|
||||
|
||||
ui.trackersTable.Refresh()
|
||||
ui.peersTable.Refresh()
|
||||
ui.filesTable.Refresh()
|
||||
|
||||
switch {
|
||||
case status.LastError != "":
|
||||
ui.errorLabel.SetText("Error: " + status.LastError)
|
||||
case manualErr != "":
|
||||
ui.errorLabel.SetText("Error: " + manualErr)
|
||||
default:
|
||||
ui.errorLabel.SetText("")
|
||||
}
|
||||
|
||||
ui.applyControlState(status.Phase)
|
||||
}
|
||||
|
||||
func (ui *desktopUI) applyControlState(phase string) {
|
||||
ui.mu.RLock()
|
||||
loading := ui.loading
|
||||
ui.mu.RUnlock()
|
||||
|
||||
busy := loading || isBusyPhase(phase)
|
||||
if busy {
|
||||
ui.startButton.Disable()
|
||||
ui.stopButton.Enable()
|
||||
ui.pathEntry.Disable()
|
||||
ui.downloadDirEntry.Disable()
|
||||
ui.browseButton.Disable()
|
||||
ui.browseOutputButton.Disable()
|
||||
return
|
||||
}
|
||||
|
||||
ui.startButton.Enable()
|
||||
ui.stopButton.Disable()
|
||||
ui.pathEntry.Enable()
|
||||
ui.downloadDirEntry.Enable()
|
||||
ui.browseButton.Enable()
|
||||
ui.browseOutputButton.Enable()
|
||||
}
|
||||
|
||||
func (ui *desktopUI) setMessage(msg string) {
|
||||
fyne.Do(func() {
|
||||
ui.setMessageOnUI(msg)
|
||||
})
|
||||
}
|
||||
|
||||
func (ui *desktopUI) setMessageOnUI(msg string) {
|
||||
ui.messageLabel.SetText(strings.TrimSpace(msg))
|
||||
}
|
||||
|
||||
func (ui *desktopUI) setError(msg string) {
|
||||
fyne.Do(func() {
|
||||
ui.setErrorOnUI(msg)
|
||||
})
|
||||
}
|
||||
|
||||
func (ui *desktopUI) setErrorOnUI(msg string) {
|
||||
msg = strings.TrimSpace(msg)
|
||||
|
||||
ui.mu.Lock()
|
||||
ui.inputError = msg
|
||||
ui.mu.Unlock()
|
||||
|
||||
if msg == "" {
|
||||
ui.errorLabel.SetText("")
|
||||
return
|
||||
}
|
||||
ui.errorLabel.SetText("Error: " + msg)
|
||||
}
|
||||
|
||||
func (ui *desktopUI) newTrackersTable() *widget.Table {
|
||||
table := widget.NewTable(
|
||||
func() (int, int) {
|
||||
ui.mu.RLock()
|
||||
defer ui.mu.RUnlock()
|
||||
return len(ui.trackers) + 1, 4
|
||||
},
|
||||
func() fyne.CanvasObject {
|
||||
return widget.NewLabel("")
|
||||
},
|
||||
func(id widget.TableCellID, obj fyne.CanvasObject) {
|
||||
label := obj.(*widget.Label)
|
||||
if id.Row == 0 {
|
||||
switch id.Col {
|
||||
case 0:
|
||||
label.SetText("Tracker")
|
||||
case 1:
|
||||
label.SetText("State")
|
||||
case 2:
|
||||
label.SetText("Peers")
|
||||
case 3:
|
||||
label.SetText("Error")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ui.mu.RLock()
|
||||
defer ui.mu.RUnlock()
|
||||
tr := ui.trackers[id.Row-1]
|
||||
switch id.Col {
|
||||
case 0:
|
||||
label.SetText(tr.URL)
|
||||
case 1:
|
||||
label.SetText(formatPhase(tr.State))
|
||||
case 2:
|
||||
label.SetText(strconv.Itoa(tr.PeerCount))
|
||||
case 3:
|
||||
label.SetText(tr.Error)
|
||||
}
|
||||
},
|
||||
)
|
||||
table.SetColumnWidth(0, 440)
|
||||
table.SetColumnWidth(1, 110)
|
||||
table.SetColumnWidth(2, 80)
|
||||
table.SetColumnWidth(3, 420)
|
||||
return table
|
||||
}
|
||||
|
||||
func (ui *desktopUI) newPeersTable() *widget.Table {
|
||||
table := widget.NewTable(
|
||||
func() (int, int) {
|
||||
ui.mu.RLock()
|
||||
defer ui.mu.RUnlock()
|
||||
return len(ui.peers) + 1, 6
|
||||
},
|
||||
func() fyne.CanvasObject {
|
||||
return widget.NewLabel("")
|
||||
},
|
||||
func(id widget.TableCellID, obj fyne.CanvasObject) {
|
||||
label := obj.(*widget.Label)
|
||||
if id.Row == 0 {
|
||||
switch id.Col {
|
||||
case 0:
|
||||
label.SetText("Address")
|
||||
case 1:
|
||||
label.SetText("Port")
|
||||
case 2:
|
||||
label.SetText("State")
|
||||
case 3:
|
||||
label.SetText("Pieces")
|
||||
case 4:
|
||||
label.SetText("Source Tracker")
|
||||
case 5:
|
||||
label.SetText("Error")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ui.mu.RLock()
|
||||
defer ui.mu.RUnlock()
|
||||
peer := ui.peers[id.Row-1]
|
||||
switch id.Col {
|
||||
case 0:
|
||||
label.SetText(peer.Address)
|
||||
case 1:
|
||||
label.SetText(strconv.Itoa(int(peer.Port)))
|
||||
case 2:
|
||||
label.SetText(formatPhase(peer.State))
|
||||
case 3:
|
||||
label.SetText(strconv.Itoa(peer.DownloadedPieces))
|
||||
case 4:
|
||||
label.SetText(peer.Source)
|
||||
case 5:
|
||||
label.SetText(peer.Error)
|
||||
}
|
||||
},
|
||||
)
|
||||
table.SetColumnWidth(0, 190)
|
||||
table.SetColumnWidth(1, 80)
|
||||
table.SetColumnWidth(2, 120)
|
||||
table.SetColumnWidth(3, 90)
|
||||
table.SetColumnWidth(4, 320)
|
||||
table.SetColumnWidth(5, 400)
|
||||
return table
|
||||
}
|
||||
|
||||
func (ui *desktopUI) newFilesTable() *widget.Table {
|
||||
table := widget.NewTable(
|
||||
func() (int, int) {
|
||||
ui.mu.RLock()
|
||||
defer ui.mu.RUnlock()
|
||||
return len(ui.files) + 1, 2
|
||||
},
|
||||
func() fyne.CanvasObject {
|
||||
return widget.NewLabel("")
|
||||
},
|
||||
func(id widget.TableCellID, obj fyne.CanvasObject) {
|
||||
label := obj.(*widget.Label)
|
||||
if id.Row == 0 {
|
||||
switch id.Col {
|
||||
case 0:
|
||||
label.SetText("File")
|
||||
case 1:
|
||||
label.SetText("Size")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ui.mu.RLock()
|
||||
defer ui.mu.RUnlock()
|
||||
file := ui.files[id.Row-1]
|
||||
switch id.Col {
|
||||
case 0:
|
||||
label.SetText(file.Path)
|
||||
case 1:
|
||||
label.SetText(formatBytes(int64(file.Length)))
|
||||
}
|
||||
},
|
||||
)
|
||||
table.SetColumnWidth(0, 820)
|
||||
table.SetColumnWidth(1, 140)
|
||||
return table
|
||||
}
|
||||
|
||||
func formatBytesRate(bytesPerSec float64) string {
|
||||
return formatByteValue(bytesPerSec, true)
|
||||
}
|
||||
|
||||
func formatBytes(bytes int64) string {
|
||||
return formatByteValue(float64(bytes), false)
|
||||
}
|
||||
|
||||
func formatByteValue(value float64, perSecond bool) string {
|
||||
if value < 0 {
|
||||
value = 0
|
||||
}
|
||||
|
||||
units := []string{"B", "KB", "MB", "GB", "TB"}
|
||||
unit := 0
|
||||
for value >= 1024 && unit < len(units)-1 {
|
||||
value /= 1024
|
||||
unit++
|
||||
}
|
||||
|
||||
suffix := ""
|
||||
if perSecond {
|
||||
suffix = "/s"
|
||||
}
|
||||
if unit == 0 {
|
||||
return fmt.Sprintf("%.0f %s%s", value, units[unit], suffix)
|
||||
}
|
||||
return fmt.Sprintf("%.2f %s%s", value, units[unit], suffix)
|
||||
}
|
||||
|
||||
func formatTransferred(done, total int64) string {
|
||||
if total <= 0 {
|
||||
return fmt.Sprintf("%s / -", formatBytes(done))
|
||||
}
|
||||
|
||||
pct := float64(done) / float64(total)
|
||||
return fmt.Sprintf("%s / %s (%s)", formatBytes(done), formatBytes(total), formatPercent(pct))
|
||||
}
|
||||
|
||||
func formatPercent(progress float64) string {
|
||||
progress = clampProgress(progress)
|
||||
return fmt.Sprintf("%.1f%%", progress*100)
|
||||
}
|
||||
|
||||
func formatProgressSummary(status torrent.Status) string {
|
||||
return fmt.Sprintf(
|
||||
"%s | %s | %s",
|
||||
formatPercent(status.Progress),
|
||||
formatTransferred(status.DownloadedBytes, status.TotalBytes),
|
||||
formatBytesRate(status.DownloadSpeed),
|
||||
)
|
||||
}
|
||||
|
||||
func formatPhase(phase string) string {
|
||||
phase = strings.TrimSpace(phase)
|
||||
if phase == "" {
|
||||
return "Idle"
|
||||
}
|
||||
|
||||
parts := strings.Split(phase, "_")
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
parts[i] = strings.ToUpper(part[:1]) + part[1:]
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func clampProgress(progress float64) float64 {
|
||||
if progress < 0 {
|
||||
return 0
|
||||
}
|
||||
if progress > 1 {
|
||||
return 1
|
||||
}
|
||||
return progress
|
||||
}
|
||||
|
||||
func isBusyPhase(phase string) bool {
|
||||
switch phase {
|
||||
case "loading_metadata", "querying_trackers", "ready", "preparing_download", "downloading", "writing_files":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue