From 368bc27eaa1d9533261064d84f41c4b8ad8be5c7 Mon Sep 17 00:00:00 2001 From: itexpert228 <67105314+fdaser1337@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:25:02 +0300 Subject: [PATCH] Refactor engine, improve UI animations, secure Telegram bot, fix seeding bugs --- .gitignore | 23 +- cmd/ztrr/main.go | 27 +- go.mod | 3 + go.sum | 7 + internal/app/controller.go | 68 +- internal/config/config.go | 79 ++ internal/dht/dht.go | 57 +- internal/dht/routing.go | 113 ++- internal/logger/logger.go | 107 ++ internal/portforward/upnp.go | 115 +++ internal/sessionlog/sessionlog.go | 185 ---- internal/sorter/sorter.go | 67 ++ internal/storage/reader.go | 42 +- internal/stream/server.go | 142 --- internal/tgbot/bot.go | 165 +++ internal/torrent/debug.go | 4 +- internal/torrent/engine.go | 276 ++++-- internal/torrent/peerwire.go | 180 +++- internal/torrentfile/torrentfile.go | 19 +- internal/tracker/tracker.go | 78 +- ui/logo.go | 287 +++--- ui/matrix.go | 175 ++++ ui/styles.go | 139 ++- ui/ui.go | 1431 ++++++++++++++++----------- 24 files changed, 2506 insertions(+), 1283 deletions(-) create mode 100644 internal/config/config.go create mode 100644 internal/logger/logger.go create mode 100644 internal/portforward/upnp.go delete mode 100644 internal/sessionlog/sessionlog.go create mode 100644 internal/sorter/sorter.go delete mode 100644 internal/stream/server.go create mode 100644 internal/tgbot/bot.go create mode 100644 ui/matrix.go diff --git a/.gitignore b/.gitignore index 338021f..4516baf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,29 @@ -/dist/ -/downloads/ +# Binaries /ztrr /ztorrent* /torrent-client* -*.log *.exe *.dll *.so *.dylib *.out +bin/ +cmd/ztrr/ztrr + +# Logs and databases +*.log +/logs/ +/downloads/ .DS_Store + +# Temporary and build files +/dist/ +/tmp/ +*.test +*.prof +.ztorrent-parts/ +profile.txt +testlip.go + +# JSON config files +*.json diff --git a/cmd/ztrr/main.go b/cmd/ztrr/main.go index 740d396..2c0b891 100644 --- a/cmd/ztrr/main.go +++ b/cmd/ztrr/main.go @@ -1,14 +1,39 @@ package main import ( + "log" + "net/http" + _ "net/http/pprof" + "os" + "github.com/veggiedefender/torrent-client/internal/app" + "github.com/veggiedefender/torrent-client/internal/config" + "github.com/veggiedefender/torrent-client/internal/tgbot" "github.com/veggiedefender/torrent-client/ui" ) func main() { + go func() { + log.Println(http.ListenAndServe("localhost:6060", nil)) + }() controller := app.NewController() - ui.Start(controller) + cfg, _ := config.Load() + var token string + if cfg != nil { + token = cfg.TelegramToken + } + if envTok := os.Getenv("TG_BOT_TOKEN"); envTok != "" { + token = envTok + } + bot, err := tgbot.NewBot(controller, token) + if err != nil { + // Log but don't fail, bot is optional + } else if bot != nil { + bot.Start() + } + + ui.Start(controller) } diff --git a/go.mod b/go.mod index 74c2dba..592bd23 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,8 @@ require ( github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 + github.com/huin/goupnp v1.3.0 github.com/jackpal/bencode-go v1.0.2 golang.org/x/time v0.15.0 ) @@ -33,6 +35,7 @@ require ( ) require ( + golang.org/x/sync v0.11.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.22.0 // indirect ) diff --git a/go.sum b/go.sum index 58769f7..27a4e4d 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,10 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= github.com/jackpal/bencode-go v1.0.2 h1:LcCNfZ344u0LpBPOZNjpCLps/wUOuN4r87Fy9+5yU8g= github.com/jackpal/bencode-go v1.0.2/go.mod h1:6jI9mUjO3GQbZti3JizEfxTzRfWOM8oBBcwbwlTfceI= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= @@ -50,6 +54,9 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= diff --git a/internal/app/controller.go b/internal/app/controller.go index 71392ae..ab38039 100644 --- a/internal/app/controller.go +++ b/internal/app/controller.go @@ -3,21 +3,22 @@ package app import ( "context" "encoding/hex" + "sync" "time" + "github.com/veggiedefender/torrent-client/internal/config" "github.com/veggiedefender/torrent-client/internal/history" - "github.com/veggiedefender/torrent-client/internal/stream" + "github.com/veggiedefender/torrent-client/internal/tgbot" "github.com/veggiedefender/torrent-client/internal/torrent" ) type Controller struct { + mu sync.Mutex engine *torrent.Engine ctx context.Context cancel context.CancelFunc currentPath string currentOut string - - streamServer *stream.Server } func NewController() *Controller { @@ -28,18 +29,18 @@ func NewController() *Controller { engine: engine, ctx: ctx, cancel: cancel, - streamServer: stream.NewServer(engine), } } func (c *Controller) StartTorrent(path, outputRoot string) error { - // Cancel any previous history updater + c.mu.Lock() if c.cancel != nil { c.cancel() } c.ctx, c.cancel = context.WithCancel(context.Background()) c.currentPath = path c.currentOut = outputRoot + c.mu.Unlock() err := c.engine.LoadTorrent(path, outputRoot) if err != nil { @@ -48,26 +49,51 @@ func (c *Controller) StartTorrent(path, outputRoot string) error { c.updateHistory() - go func() { + go func(ctx context.Context) { ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() for { select { - case <-c.ctx.Done(): + case <-ctx.Done(): return case <-ticker.C: c.updateHistory() } } - }() + }(c.ctx) return nil } +func (c *Controller) DownloadMagnet(magnetURI string) error { + cfg, _ := config.Load() + outDir := "." + if cfg != nil && cfg.DefaultDir != "" { + outDir = cfg.DefaultDir + } + return c.StartTorrent(magnetURI, outDir) +} + +func (c *Controller) InitTelegramBot(token string) error { + if token == "" { + return nil + } + bot, err := tgbot.NewBot(c, token) + if err != nil { + return err + } + if bot != nil { + bot.Start() + } + return nil +} + func (c *Controller) StopTorrent() { + c.mu.Lock() if c.cancel != nil { c.cancel() } + c.mu.Unlock() c.updateHistory() c.engine.Stop() } @@ -83,11 +109,16 @@ func (c *Controller) updateHistory() { hashHex = hex.EncodeToString(status.InfoHash[:]) } + c.mu.Lock() + path := c.currentPath + out := c.currentOut + c.mu.Unlock() + hItem := history.Item{ InfoHash: hashHex, Name: status.Name, - TorrentPath: c.currentPath, - OutputDir: c.currentOut, + TorrentPath: path, + OutputDir: out, Status: status.Phase, Progress: c.engine.Progress() * 100, // store as percentage 0-100 Size: status.TotalBytes, @@ -100,17 +131,14 @@ func (c *Controller) Progress() float64 { } func (c *Controller) Status() torrent.Status { + if c.engine == nil { + return torrent.Status{} + } return c.engine.Status() } -func (c *Controller) SetSequentialMode(mode bool) { - c.engine.SetSequentialMode(mode) -} - -func (c *Controller) StartStreamServer() { - c.streamServer.Start() -} - -func (c *Controller) StopStreamServer() { - c.streamServer.Stop() +func (c *Controller) ToggleFilePriority(fileIdx int) { + if c.engine != nil { + c.engine.ToggleFilePriority(fileIdx) + } } diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..1624389 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,79 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" +) + +type Config struct { + TelegramToken string `json:"telegram_token"` + TelegramOwnerID int64 `json:"telegram_owner_id"` + DefaultDir string `json:"default_dir"` + DownloadLimit int `json:"download_limit_kbs"` // 0 = no limit + UploadLimit int `json:"upload_limit_kbs"` // 0 = no limit + DisableAnimations bool `json:"disable_animations"` +} + +var ( + mu sync.Mutex +) + +func getConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".ztrr", "config.json"), nil +} + +func Load() (*Config, error) { + mu.Lock() + defer mu.Unlock() + + path, err := getConfigPath() + if err != nil { + return nil, err + } + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &Config{}, nil + } + return nil, err + } + + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +func Save(cfg *Config) error { + mu.Lock() + defer mu.Unlock() + + path, err := getConfigPath() + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + + tmpPath := path + ".tmp" + if err := os.WriteFile(tmpPath, data, 0644); err != nil { + return err + } + + return os.Rename(tmpPath, path) +} diff --git a/internal/dht/dht.go b/internal/dht/dht.go index 35bb0eb..4c83ef3 100644 --- a/internal/dht/dht.go +++ b/internal/dht/dht.go @@ -6,12 +6,12 @@ import ( "crypto/rand" "encoding/hex" "fmt" - "log" "net" "strings" "sync" "time" + "github.com/veggiedefender/torrent-client/internal/logger" "github.com/veggiedefender/torrent-client/internal/tracker" ) @@ -69,7 +69,7 @@ func (s *Server) Start(ctx context.Context, port int) error { go s.readLoop(ctx) go s.bootstrap(ctx) - log.Printf("DHT Server listening on %s with ID %x", conn.LocalAddr(), s.ID[:8]) + logger.Info("DHT", "DHT Server listening on %s with ID %x", conn.LocalAddr(), s.ID[:8]) return nil } @@ -170,9 +170,19 @@ func (s *Server) handleMsg(msg Msg, from *net.UDPAddr) { } 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) + var tid string + s.transactionsMu.Lock() + for { + tidBytes := make([]byte, 2) + rand.Read(tidBytes) + tid = string(tidBytes) + if _, exists := s.transactions[tid]; !exists { + break + } + } + ch := make(chan Msg, 1) + s.transactions[tid] = ch + s.transactionsMu.Unlock() a["id"] = string(s.ID[:]) msg := NewQuery(tid, q, a) @@ -181,11 +191,6 @@ func (s *Server) sendQuery(ctx context.Context, addr *net.UDPAddr, q string, a m 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) @@ -233,20 +238,20 @@ func (s *Server) bootstrap(ctx context.Context) { "target": string(s.ID[:]), }) if err != nil { - log.Printf("DHT bootstrap %s failed: %v", name, err) + logger.Info("DHT", "DHT bootstrap %s failed: %v", name, err) return } if resp.R != nil { if nodesStr, ok := resp.R["nodes"].(string); ok { count := len(nodesStr) / 26 - log.Printf("DHT bootstrap %s: got %d nodes", name, count) + logger.Info("DHT", "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()) + logger.Info("DHT", "DHT bootstrap done, routing table: %d nodes", s.routingTable.Len()) } // SearchForPeers непрерывно ищет пиров для данного info_hash. @@ -259,7 +264,7 @@ func (s *Server) SearchForPeers(ctx context.Context, infoHash [20]byte) { } time.Sleep(300 * time.Millisecond) } - log.Printf("DHT SearchForPeers starting, routing table: %d nodes", s.routingTable.Len()) + logger.Info("DHT", "DHT SearchForPeers starting, routing table: %d nodes", s.routingTable.Len()) targetID := NodeID(infoHash) queried := make(map[string]bool) // ключ = IP:port строка @@ -285,27 +290,21 @@ func (s *Server) SearchForPeers(ctx context.Context, infoHash [20]byte) { } if len(toQuery) == 0 { - // Нет новых нод — сбрасываем карту уже запрошенных и пробуем снова - // (новые ноды могли добавиться в routing table) - log.Printf("DHT: no new nodes to query (%d total queried), resetting and retrying", len(queried)) + logger.Info("DHT", "DHT: no new nodes to query (%d total queried), waiting before retry", 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): + case <-time.After(30 * 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{}{ @@ -330,17 +329,23 @@ func (s *Server) SearchForPeers(ctx context.Context, infoHash [20]byte) { } } if peers, err := tracker.ParsePeers(allPeerData); err == nil && len(peers) > 0 { - log.Printf("DHT: found %d peers from %s", len(peers), node.Addr) + logger.Info("DHT", "DHT: found %d peers from %s", len(peers), node.Addr) select { case s.PeersFound <- peers: - default: - // канал полный — пытаемся без блокировки + case <-ctx.Done(): + return + case <-time.After(2 * time.Second): } } } }(n) } - wg.Wait() + + select { + case <-ctx.Done(): + return + case <-time.After(500 * time.Millisecond): + } } } diff --git a/internal/dht/routing.go b/internal/dht/routing.go index 1c3e2fc..0ee5dd3 100644 --- a/internal/dht/routing.go +++ b/internal/dht/routing.go @@ -33,48 +33,76 @@ func Distance(a, b NodeID) *big.Int { return new(big.Int).SetBytes(xor[:]) } -// RoutingTable manages known nodes. -type RoutingTable struct { - mu sync.RWMutex - ownID NodeID +// Bucket represents a Kademlia k-bucket. +type Bucket struct { nodes []Node } +// RoutingTable manages known nodes using Kademlia k-buckets. +type RoutingTable struct { + mu sync.RWMutex + ownID NodeID + buckets [160]*Bucket +} + // NewRoutingTable creates a new routing table. func NewRoutingTable(ownID NodeID) *RoutingTable { - return &RoutingTable{ + rt := &RoutingTable{ ownID: ownID, - nodes: make([]Node, 0), } + for i := 0; i < 160; i++ { + rt.buckets[i] = &Bucket{nodes: make([]Node, 0, MaxNodes)} + } + return rt +} + +// bucketIndex calculates the appropriate bucket index for a given node ID. +// Returns an index from 0 to 159, or -1 if the ID is our own. +func (rt *RoutingTable) bucketIndex(target NodeID) int { + for i := 0; i < 20; i++ { + xor := rt.ownID[i] ^ target[i] + if xor != 0 { + // Find the most significant bit set in the byte + for j := 7; j >= 0; j-- { + if (xor & (1 << j)) != 0 { + return 159 - (i*8 + (7 - j)) + } + } + } + } + return -1 } // AddNode adds a node to the routing table or updates it. func (rt *RoutingTable) AddNode(n Node) { + idx := rt.bucketIndex(n.ID) + if idx == -1 { + return // Do not add ourselves + } + rt.mu.Lock() defer rt.mu.Unlock() - if n.ID == rt.ownID { - return - } + bucket := rt.buckets[idx] - for i, existing := range rt.nodes { + // Check if already exists and update + for i, existing := range bucket.nodes { if existing.ID == n.ID { - rt.nodes[i].Addr = n.Addr + bucket.nodes[i].Addr = n.Addr + // Move to end (most recently seen) + node := bucket.nodes[i] + bucket.nodes = append(bucket.nodes[:i], bucket.nodes[i+1:]...) + bucket.nodes = append(bucket.nodes, node) return } } - 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] + // Add new node if bucket is not full + if len(bucket.nodes) < MaxNodes { + bucket.nodes = append(bucket.nodes, n) + } else { + // In a full Kademlia implementation, we would ping the oldest node + // and replace it if it doesn't respond. For now, just drop the new one. } } @@ -83,26 +111,43 @@ 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) + var allNodes []Node + + // Fast path: find the target's bucket + idx := rt.bucketIndex(target) + if idx != -1 { + allNodes = append(allNodes, rt.buckets[idx].nodes...) + } - sort.Slice(sortedNodes, func(i, j int) bool { - distI := Distance(sortedNodes[i].ID, target) - distJ := Distance(sortedNodes[j].ID, target) + // Collect nodes from neighboring buckets if we don't have enough + if len(allNodes) < count { + // We just collect all nodes and sort them for simplicity. + // Optimizing this is possible but not strictly necessary for < 1280 nodes total. + allNodes = nil + for i := 0; i < 160; i++ { + allNodes = append(allNodes, rt.buckets[i].nodes...) + } + } + + sort.Slice(allNodes, func(i, j int) bool { + distI := Distance(allNodes[i].ID, target) + distJ := Distance(allNodes[j].ID, target) return distI.Cmp(distJ) < 0 }) - if len(sortedNodes) > count { - return sortedNodes[:count] + if len(allNodes) > count { + return allNodes[:count] } - return sortedNodes + return allNodes } -// Len returns the number of nodes in the routing table. +// Len returns the total number of nodes in the routing table. func (rt *RoutingTable) Len() int { rt.mu.RLock() defer rt.mu.RUnlock() - return len(rt.nodes) + count := 0 + for i := 0; i < 160; i++ { + count += len(rt.buckets[i].nodes) + } + return count } diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..d868625 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,107 @@ +package logger + +import ( + "fmt" + "sync" + "time" +) + +type Level int + +const ( + LevelDebug Level = iota + LevelInfo + LevelWarn + LevelError +) + +func (l Level) String() string { + switch l { + case LevelDebug: + return "DEBUG" + case LevelInfo: + return "INFO" + case LevelWarn: + return "WARN" + case LevelError: + return "ERROR" + default: + return "UNKNOWN" + } +} + +type Entry struct { + Time time.Time + Level Level + Category string + Message string +} + +type RingBuffer struct { + mu sync.RWMutex + entries []Entry + head int + count int + size int +} + +func NewRingBuffer(size int) *RingBuffer { + return &RingBuffer{ + entries: make([]Entry, size), + size: size, + } +} + +func (b *RingBuffer) Add(e Entry) { + b.mu.Lock() + defer b.mu.Unlock() + + b.entries[b.head] = e + b.head = (b.head + 1) % b.size + if b.count < b.size { + b.count++ + } +} + +func (b *RingBuffer) Snapshot() []Entry { + b.mu.RLock() + defer b.mu.RUnlock() + + result := make([]Entry, b.count) + for i := 0; i < b.count; i++ { + idx := (b.head - b.count + i + b.size) % b.size + result[i] = b.entries[idx] + } + return result +} + +// Global logger instance +var ( + GlobalBuffer = NewRingBuffer(1000) +) + +func logf(level Level, category string, format string, v ...interface{}) { + msg := fmt.Sprintf(format, v...) + GlobalBuffer.Add(Entry{ + Time: time.Now(), + Level: level, + Category: category, + Message: msg, + }) +} + +func Debug(category string, format string, v ...interface{}) { + logf(LevelDebug, category, format, v...) +} + +func Info(category string, format string, v ...interface{}) { + logf(LevelInfo, category, format, v...) +} + +func Warn(category string, format string, v ...interface{}) { + logf(LevelWarn, category, format, v...) +} + +func Error(category string, format string, v ...interface{}) { + logf(LevelError, category, format, v...) +} diff --git a/internal/portforward/upnp.go b/internal/portforward/upnp.go new file mode 100644 index 0000000..4799735 --- /dev/null +++ b/internal/portforward/upnp.go @@ -0,0 +1,115 @@ +package portforward + +import ( + "context" + "net" + "sync" + "time" + + "github.com/huin/goupnp/dcps/internetgateway1" + "github.com/veggiedefender/torrent-client/internal/logger" +) + +type PortManager struct { + mu sync.Mutex + clients []*internetgateway1.WANIPConnection1 + port uint16 + opened bool +} + +func NewPortManager() *PortManager { + return &PortManager{} +} + +// OpenPort attempts to open the specified port on all discovered UPnP routers. +func (pm *PortManager) OpenPort(ctx context.Context, port uint16, desc string) error { + pm.mu.Lock() + defer pm.mu.Unlock() + + if pm.opened && pm.port == port { + return nil + } + + logger.Info("SYS", "Discovering UPnP routers for port %d...", port) + + discoverCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + discoverDone := make(chan []*internetgateway1.WANIPConnection1, 1) + go func() { + clients, _, _ := internetgateway1.NewWANIPConnection1Clients() + discoverDone <- clients + }() + + var clients []*internetgateway1.WANIPConnection1 + select { + case <-discoverCtx.Done(): + logger.Warn("SYS", "UPnP discovery timed out") + return nil + case clients = <-discoverDone: + } + + if len(clients) == 0 { + logger.Warn("SYS", "No UPnP routers discovered") + return nil + } + + localIP, err := getLocalIP() + if err != nil { + logger.Warn("SYS", "Could not get local IP for UPnP") + return nil + } + + success := false + for _, client := range clients { + // TCP Mapping + errTCP := client.AddPortMapping("", port, "TCP", port, localIP, true, desc, 0) + // UDP Mapping (for DHT) + errUDP := client.AddPortMapping("", port, "UDP", port, localIP, true, desc, 0) + + if errTCP == nil || errUDP == nil { + success = true + } + } + + if success { + pm.clients = clients + pm.port = port + pm.opened = true + logger.Info("SYS", "UPnP successfully forwarded port %d", port) + } else { + logger.Warn("SYS", "Failed to add UPnP port mapping") + } + + return nil +} + +// ClosePort closes the currently opened port. +func (pm *PortManager) ClosePort() error { + pm.mu.Lock() + defer pm.mu.Unlock() + + if !pm.opened || len(pm.clients) == 0 { + return nil + } + + for _, client := range pm.clients { + _ = client.DeletePortMapping("", pm.port, "TCP") + _ = client.DeletePortMapping("", pm.port, "UDP") + } + + logger.Info("SYS", "UPnP successfully cleared port %d", pm.port) + pm.opened = false + return nil +} + +// getLocalIP returns the preferred outbound IP of this machine +func getLocalIP() (string, error) { + conn, err := net.Dial("udp", "8.8.8.8:80") + if err != nil { + return "", err + } + defer conn.Close() + localAddr := conn.LocalAddr().(*net.UDPAddr) + return localAddr.IP.String(), nil +} diff --git a/internal/sessionlog/sessionlog.go b/internal/sessionlog/sessionlog.go deleted file mode 100644 index fb3cc18..0000000 --- a/internal/sessionlog/sessionlog.go +++ /dev/null @@ -1,185 +0,0 @@ -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() -} diff --git a/internal/sorter/sorter.go b/internal/sorter/sorter.go new file mode 100644 index 0000000..9a87540 --- /dev/null +++ b/internal/sorter/sorter.go @@ -0,0 +1,67 @@ +package sorter + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + + "github.com/veggiedefender/torrent-client/internal/logger" +) + +var ( + tvShowRegex = regexp.MustCompile(`(?i)(s\d{2}e\d{2}|season\s*\d+|сезон\s*\d+)`) + movieRegex = regexp.MustCompile(`(?i)(1080p|720p|2160p|4k|bdrip|web-dl|bluray|camrip)`) + gameRegex = regexp.MustCompile(`(?i)(repack|fitgirl|dodi|skidrow|codex|crack|iso)`) + musicRegex = regexp.MustCompile(`(?i)(discography|flac|mp3\s*320)`) +) + +// Categorize analyzes the torrent name and determines its category. +func Categorize(name string) string { + if tvShowRegex.MatchString(name) { + return "TV Shows" + } + if movieRegex.MatchString(name) { + return "Movies" + } + if gameRegex.MatchString(name) { + return "Games" + } + if musicRegex.MatchString(name) { + return "Music" + } + return "Other" +} + +// SortAndMove moves the downloaded files (or folder) to a sub-folder based on its category. +func SortAndMove(outputRoot, torrentName string) (string, error) { + category := Categorize(torrentName) + if category == "Other" || category == "" { + return "", nil // Do not move if category is unknown + } + + sourcePath := filepath.Join(outputRoot, torrentName) + if _, err := os.Stat(sourcePath); os.IsNotExist(err) { + return "", fmt.Errorf("source path does not exist: %s", sourcePath) + } + + destDir := filepath.Join(outputRoot, category) + if err := os.MkdirAll(destDir, 0o755); err != nil { + return "", fmt.Errorf("failed to create category directory: %w", err) + } + + destPath := filepath.Join(destDir, torrentName) + + // If it already exists in the destination, maybe we resume/overwrite + if _, err := os.Stat(destPath); err == nil { + logger.Warn("SYS", "Destination path already exists, skipping move: %s", destPath) + return "", nil + } + + if err := os.Rename(sourcePath, destPath); err != nil { + return "", fmt.Errorf("failed to move files to category folder: %w", err) + } + + logger.Info("SYS", "Smart Sorter moved '%s' to '%s'", torrentName, category) + return destDir, nil +} diff --git a/internal/storage/reader.go b/internal/storage/reader.go index c362ede..7dae6b0 100644 --- a/internal/storage/reader.go +++ b/internal/storage/reader.go @@ -5,6 +5,7 @@ import ( "io" "os" "path/filepath" + "sync" "github.com/veggiedefender/torrent-client/internal/torrentfile" ) @@ -15,6 +16,9 @@ type PieceReader struct { outputRoot string pieceLength int totalLength int + + mu sync.Mutex + fdMap map[string]*os.File } // NewPieceReader creates a new PieceReader. @@ -24,9 +28,36 @@ func NewPieceReader(files []torrentfile.File, pieceLength, totalLength int, outp outputRoot: outputRoot, pieceLength: pieceLength, totalLength: totalLength, + fdMap: make(map[string]*os.File), } } +// Close closes all open file descriptors +func (pr *PieceReader) Close() { + pr.mu.Lock() + defer pr.mu.Unlock() + for _, f := range pr.fdMap { + f.Close() + } + pr.fdMap = make(map[string]*os.File) +} + +func (pr *PieceReader) getFile(path string) (*os.File, error) { + pr.mu.Lock() + defer pr.mu.Unlock() + + if f, ok := pr.fdMap[path]; ok { + return f, nil + } + + f, err := os.Open(path) + if err != nil { + return nil, err + } + pr.fdMap[path] = f + return f, nil +} + // ReadBlock reads a specific block of data from the files. func (pr *PieceReader) ReadBlock(pieceIndex, begin, length int) ([]byte, error) { absoluteOffset := pieceIndex*pr.pieceLength + begin @@ -59,18 +90,21 @@ func (pr *PieceReader) ReadBlock(pieceIndex, begin, length int) ([]byte, error) } filePath := filepath.Join(pr.outputRoot, file.Path) - f, err := os.Open(filePath) + f, err := pr.getFile(filePath) if err != nil { return nil, err } - _, err = f.ReadAt(buf[bytesRead:bytesRead+readLength], int64(fileOffset)) - f.Close() + n, err := f.ReadAt(buf[bytesRead:bytesRead+readLength], int64(fileOffset)) if err != nil && err != io.EOF { return nil, err } - bytesRead += readLength + bytesRead += n + if n < readLength { + break // EOF reached prematurely + } + currentOffset += file.Length } diff --git a/internal/stream/server.go b/internal/stream/server.go deleted file mode 100644 index 421dc73..0000000 --- a/internal/stream/server.go +++ /dev/null @@ -1,142 +0,0 @@ -package stream - -import ( - "context" - "fmt" - "io" - "log" - "net/http" - "os" - "time" - - "github.com/veggiedefender/torrent-client/internal/torrent" -) - -// Server handles HTTP streaming for a torrent -type Server struct { - server *http.Server - engine *torrent.Engine -} - -func NewServer(engine *torrent.Engine) *Server { - s := &Server{ - engine: engine, - } - - mux := http.NewServeMux() - mux.HandleFunc("/stream", s.handleStream) - - s.server = &http.Server{ - Addr: ":8080", - Handler: mux, - } - - return s -} - -func (s *Server) Start() { - go func() { - log.Println("Starting stream server on http://localhost:8080/stream") - if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Printf("Stream server error: %v", err) - } - }() -} - -func (s *Server) Stop() { - if s.server != nil { - s.server.Shutdown(context.Background()) - } -} - -func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) { - status := s.engine.Status() - if !status.Loaded { - http.Error(w, "Torrent not loaded", http.StatusNotFound) - return - } - - partPath := s.engine.PartFilePath() - if partPath == "" { - http.Error(w, "Part file not ready", http.StatusServiceUnavailable) - return - } - - file, err := os.Open(partPath) - if err != nil { - http.Error(w, "Failed to open file", http.StatusInternalServerError) - return - } - // Note: We don't defer file.Close() here because ServeContent uses the Seeker asynchronously, - // wait, http.ServeContent does NOT close it, but it finishes before returning! - // So we MUST defer file.Close(). - defer file.Close() - - reader := &streamReader{ - file: file, - engine: s.engine, - pieceLength: status.PieceLength, - length: int64(status.Length), - offset: 0, - } - - w.Header().Set("Content-Type", "video/mp4") // TODO: detect properly, default to mp4 - w.Header().Set("Accept-Ranges", "bytes") - - http.ServeContent(w, r, status.Name, time.Now(), reader) -} - -// streamReader wraps a file and blocks until requested pieces are downloaded -type streamReader struct { - file *os.File - engine *torrent.Engine - pieceLength int - length int64 - offset int64 -} - -func (r *streamReader) Read(p []byte) (n int, err error) { - if r.offset >= r.length { - return 0, io.EOF - } - - // Calculate which piece we are trying to read - pieceIndex := int(r.offset / int64(r.pieceLength)) - - // Polling loop to wait for the piece - for !r.engine.HasPiece(pieceIndex) { - status := r.engine.Status() - if status.Phase == "failed" || status.Phase == "stopped" { - return 0, fmt.Errorf("torrent stopped or failed") - } - time.Sleep(200 * time.Millisecond) - } - - // Read from the actual file - n, err = r.file.ReadAt(p, r.offset) - if n > 0 { - r.offset += int64(n) - } - return n, err -} - -func (r *streamReader) Seek(offset int64, whence int) (int64, error) { - var newOffset int64 - switch whence { - case io.SeekStart: - newOffset = offset - case io.SeekCurrent: - newOffset = r.offset + offset - case io.SeekEnd: - newOffset = r.length + offset - default: - return 0, fmt.Errorf("invalid whence: %d", whence) - } - - if newOffset < 0 { - return 0, fmt.Errorf("negative offset") - } - - r.offset = newOffset - return r.offset, nil -} diff --git a/internal/tgbot/bot.go b/internal/tgbot/bot.go new file mode 100644 index 0000000..5870f5d --- /dev/null +++ b/internal/tgbot/bot.go @@ -0,0 +1,165 @@ +package tgbot + +import ( + "fmt" + "io" + "net/http" + "os" + "strings" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/veggiedefender/torrent-client/internal/config" + "github.com/veggiedefender/torrent-client/internal/logger" +) + +type Controller interface { + DownloadMagnet(magnetURI string) error + // You can add more methods if you want to support downloading .torrent files or getting status. +} + +type Bot struct { + api *tgbotapi.BotAPI + controller Controller +} + +func NewBot(ctrl Controller, token string) (*Bot, error) { + if token == "" { + // Bot is disabled if no token is provided. + logger.Info("TGBOT", "TG_BOT_TOKEN not set, bot integration is disabled") + return nil, nil + } + + api, err := tgbotapi.NewBotAPI(token) + if err != nil { + return nil, fmt.Errorf("failed to create bot: %w", err) + } + + logger.Info("TGBOT", "Authorized on account %s", api.Self.UserName) + + return &Bot{ + api: api, + controller: ctrl, + }, nil +} + +func (b *Bot) Start() { + if b == nil || b.api == nil { + return + } + + u := tgbotapi.NewUpdate(0) + u.Timeout = 60 + + updates := b.api.GetUpdatesChan(u) + + go func() { + cfg, _ := config.Load() + var ownerID int64 + if cfg != nil { + ownerID = cfg.TelegramOwnerID + } + + for update := range updates { + if update.Message == nil { + continue + } + + // Authorization Check + if ownerID == 0 { + if update.Message.Text == "/start" { + ownerID = update.Message.From.ID + if cfg == nil { + cfg = &config.Config{} + } + cfg.TelegramOwnerID = ownerID + _ = config.Save(cfg) + b.api.Send(tgbotapi.NewMessage(update.Message.Chat.ID, "You are now registered as the owner of this Ztorrent instance.")) + } else { + b.api.Send(tgbotapi.NewMessage(update.Message.Chat.ID, "Ztorrent is waiting for the owner to send /start.")) + } + continue + } else if update.Message.From.ID != ownerID { + logger.Warn("TGBOT", "Unauthorized access attempt from %s", update.Message.From.UserName) + continue + } + + // Check for documents (.torrent files) + if update.Message.Document != nil { + doc := update.Message.Document + if strings.HasSuffix(strings.ToLower(doc.FileName), ".torrent") { + logger.Info("TGBOT", "Received .torrent file from %s: %s", update.Message.From.UserName, doc.FileName) + + // Get file URL from Telegram + fileURL, err := b.api.GetFileDirectURL(doc.FileID) + if err != nil { + b.api.Send(tgbotapi.NewMessage(update.Message.Chat.ID, fmt.Sprintf("Error getting file: %v", err))) + continue + } + + b.api.Send(tgbotapi.NewMessage(update.Message.Chat.ID, "Torrent file received, starting download...")) + + // Download file contents async + go func(chatId int64, url, name string) { + err := b.downloadAndStartTorrent(chatId, url, name) + if err != nil { + b.api.Send(tgbotapi.NewMessage(chatId, fmt.Sprintf("Error starting torrent: %v", err))) + } else { + b.api.Send(tgbotapi.NewMessage(chatId, "Download started successfully!")) + } + }(update.Message.Chat.ID, fileURL, doc.FileName) + + continue + } + } + + text := strings.TrimSpace(update.Message.Text) + + if strings.HasPrefix(text, "magnet:?") { + logger.Info("TGBOT", "Received magnet link from %s", update.Message.From.UserName) + msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Magnet link received, starting download...") + b.api.Send(msg) + + err := b.controller.DownloadMagnet(text) + if err != nil { + errMsg := tgbotapi.NewMessage(update.Message.Chat.ID, fmt.Sprintf("Error starting download: %v", err)) + b.api.Send(errMsg) + } else { + succMsg := tgbotapi.NewMessage(update.Message.Chat.ID, "Download started successfully!") + b.api.Send(succMsg) + } + continue + } + + if text == "/start" { + b.api.Send(tgbotapi.NewMessage(update.Message.Chat.ID, "Welcome back, Owner! Send me a magnet link or a .torrent file.")) + } + } + }() +} + +func (b *Bot) downloadAndStartTorrent(chatID int64, fileURL, fileName string) error { + resp, err := http.Get(fileURL) + if err != nil { + return fmt.Errorf("failed to download file from telegram: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("bad status code: %d", resp.StatusCode) + } + + out, err := os.CreateTemp("", "*_"+fileName) + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + tmpPath := out.Name() + + _, err = io.Copy(out, resp.Body) + out.Close() + if err != nil { + return fmt.Errorf("failed to write temp file: %w", err) + } + + // We pass the local path to controller + return b.controller.DownloadMagnet(tmpPath) +} diff --git a/internal/torrent/debug.go b/internal/torrent/debug.go index 80ca281..c258ee6 100644 --- a/internal/torrent/debug.go +++ b/internal/torrent/debug.go @@ -1,6 +1,6 @@ package torrent -import "log" +import "github.com/veggiedefender/torrent-client/internal/logger" // debug enables verbose protocol and scheduler logs. var debug = true @@ -9,7 +9,7 @@ func debugf(format string, args ...any) { if !debug { return } - log.Printf(format, args...) + logger.Debug("ENGINE", format, args...) } func SetDebug(enabled bool) { diff --git a/internal/torrent/engine.go b/internal/torrent/engine.go index 5dd032e..dbc94f3 100644 --- a/internal/torrent/engine.go +++ b/internal/torrent/engine.go @@ -10,7 +10,6 @@ import ( "errors" "fmt" "io" - "log" mathrand "math/rand" "net" "os" @@ -26,7 +25,10 @@ import ( "golang.org/x/time/rate" "github.com/veggiedefender/torrent-client/internal/dht" + "github.com/veggiedefender/torrent-client/internal/logger" "github.com/veggiedefender/torrent-client/internal/magnet" + "github.com/veggiedefender/torrent-client/internal/portforward" + "github.com/veggiedefender/torrent-client/internal/sorter" "github.com/veggiedefender/torrent-client/internal/storage" "github.com/veggiedefender/torrent-client/internal/torrentfile" "github.com/veggiedefender/torrent-client/internal/tracker" @@ -42,6 +44,7 @@ type Engine struct { peerID [20]byte dhtServer *dht.Server + portManager *portforward.PortManager cancel context.CancelFunc @@ -297,6 +300,7 @@ func NewEngine() *Engine { peerID: generatePeerID(), phase: "idle", incomingConns: make(chan net.Conn, 128), + portManager: portforward.NewPortManager(), } } @@ -336,7 +340,7 @@ func (e *Engine) LoadTorrent(path, outputRoot string) error { e.resetStateForNewLoad() e.setPhase("loading_metadata") outputRoot = normalizeOutputRoot(outputRoot) - log.Printf("loading torrent metadata from %s", path) + logger.Info("ENGINE", "loading torrent metadata from %s", path) debugf("download root selected: %s", outputRoot) downloadCtx, cancel := context.WithCancel(context.Background()) @@ -349,7 +353,7 @@ func (e *Engine) LoadTorrent(path, outputRoot string) error { tf, err := torrentfile.Open(path) if err != nil { - log.Printf("failed to parse torrent %s: %v", path, err) + logger.Info("ENGINE", "failed to parse torrent %s: %v", path, err) e.setError(err) e.setPhase("failed") cancel() @@ -424,29 +428,29 @@ func (e *Engine) loadMagnet(ctx context.Context, uri, outputRoot string) { func (e *Engine) downloadMetadataFromPeers(ctx context.Context, infoHash [20]byte, name string, peersCh <-chan []PeerStatus, initialPeers []PeerStatus) (*torrentfile.TorrentFile, error) { resultCh := make(chan *torrentfile.TorrentFile, 1) - + workCtx, workCancel := context.WithCancel(ctx) defer workCancel() tryPeer := func(p PeerStatus) { addr := fmt.Sprintf("%s:%d", p.Address, p.Port) - + dialCtx, cancel := context.WithTimeout(workCtx, 10*time.Second) pc, err := newPeerClient(dialCtx, addr, infoHash, e.peerID, 0) cancel() if err != nil { - log.Printf("peer dial failed %s: %v", addr, err) + logger.Info("ENGINE", "peer dial failed %s: %v", addr, err) return } defer pc.Close() if pc.PeerUtMetadataID() == 0 { - log.Printf("peer %s: no ut_metadata support", addr) + logger.Info("ENGINE", "peer %s: no ut_metadata support", addr) return } size := pc.MetadataSize() - log.Printf("peer %s: metadata size=%d, ut_metadata=%d", addr, size, pc.PeerUtMetadataID()) + logger.Info("ENGINE", "peer %s: metadata size=%d, ut_metadata=%d", addr, size, pc.PeerUtMetadataID()) if size <= 0 || size > 10*1024*1024 { return } @@ -542,7 +546,7 @@ func (e *Engine) startTorrent(downloadCtx context.Context, tf *torrentfile.Torre } e.phase = "ready" e.mu.Unlock() - log.Printf("torrent loaded: name=%s size=%d peers=%d output=%s", tf.Name, tf.Length, len(peers), e.outputPath) + logger.Info("ENGINE", "torrent loaded: name=%s size=%d peers=%d output=%s", tf.Name, tf.Length, len(peers), e.outputPath) go e.runDownload(downloadCtx, tf) } @@ -628,7 +632,7 @@ func (e *Engine) queryTrackers(ctx context.Context, trackersToTry []string, info go func() { defer wg.Done() started := time.Now() - log.Printf("tracker request started: %s", announce) + logger.Info("ENGINE", "tracker request started: %s", announce) perTrackerCtx, cancel := context.WithTimeout(ctx, opts.Timeout) peers, err := tracker.GetPeersFromURL(perTrackerCtx, announce, infoHash, length, opts) @@ -652,7 +656,7 @@ func (e *Engine) queryTrackers(ctx context.Context, trackersToTry []string, info if res.err != nil { st.State = "error" st.Error = res.err.Error() - log.Printf("tracker response received: %s state=error elapsed=%s err=%v", res.announce, res.elapsed.Round(time.Millisecond), res.err) + logger.Info("ENGINE", "tracker response received: %s state=error elapsed=%s err=%v", res.announce, res.elapsed.Round(time.Millisecond), res.err) statuses = append(statuses, st) continue } @@ -664,7 +668,7 @@ func (e *Engine) queryTrackers(ctx context.Context, trackersToTry []string, info st.PeerCount = len(res.peers) } statuses = append(statuses, st) - log.Printf("tracker response received: %s state=%s peers=%d elapsed=%s", res.announce, st.State, len(res.peers), res.elapsed.Round(time.Millisecond)) + logger.Info("ENGINE", "tracker response received: %s state=%s peers=%d elapsed=%s", res.announce, st.State, len(res.peers), res.elapsed.Round(time.Millisecond)) for _, peer := range res.peers { addPeer(peerMap, peer, res.announce) @@ -698,7 +702,7 @@ func (e *Engine) ensureDHTServer(ctx context.Context) { if err := srv.Start(ctx, dht.Port); err == nil { e.dhtServer = srv } else { - log.Printf("Failed to start DHT server: %v", err) + logger.Info("ENGINE", "Failed to start DHT server: %v", err) } } @@ -730,11 +734,11 @@ func (e *Engine) runDownload(ctx context.Context, tf *torrentfile.TorrentFile) { go e.listenIncoming(ctx, tf) e.setPhase("preparing_download") - log.Printf("preparing download to %s", e.outputRoot) + logger.Info("ENGINE", "preparing download to %s", e.outputRoot) partPath, partFile, resumedPieces, err := createOrOpenPartFile(tf, e.outputRoot) if err != nil { - log.Printf("failed to create part file: %v", err) + logger.Info("ENGINE", "failed to create part file: %v", err) e.setTerminalError("failed", fmt.Errorf("create temp file: %w", err)) return } @@ -743,7 +747,50 @@ func (e *Engine) runDownload(ctx context.Context, tf *torrentfile.TorrentFile) { e.mu.Unlock() defer partFile.Close() - // Восстанавливаем состояние уже скачанных кусков + // Определяем куски, которые нужно пропустить (состоят только из файлов с Priority == 0) + skippedPieces := make(map[int]bool) + { + fileOffset := int64(0) + fileSkippedRanges := make([][2]int64, 0) + for _, f := range tf.Files { + if f.Priority == 0 { + fileSkippedRanges = append(fileSkippedRanges, [2]int64{fileOffset, fileOffset + int64(f.Length)}) + } + fileOffset += int64(f.Length) + } + + for i := 0; i < len(tf.PieceHashes); i++ { + pStart := int64(i * tf.PieceLength) + pEnd := pStart + int64(tf.PieceLength) + if pEnd > int64(tf.Length) { + pEnd = int64(tf.Length) + } + + // Проверяем, покрыт ли кусок полностью skipped файлами + covered := int64(0) + for _, r := range fileSkippedRanges { + // пересечение [pStart, pEnd) и [r[0], r[1]) + start := pStart + if r[0] > start { + start = r[0] + } + end := pEnd + if r[1] < end { + end = r[1] + } + if start < end { + covered += (end - start) + } + } + + if covered >= (pEnd - pStart) { + skippedPieces[i] = true + resumedPieces = append(resumedPieces, i) + } + } + } + + // Восстанавливаем состояние уже скачанных кусков (включая пропущенные) if len(resumedPieces) > 0 { e.mu.Lock() for _, idx := range resumedPieces { @@ -757,7 +804,7 @@ func (e *Engine) runDownload(ctx context.Context, tf *torrentfile.TorrentFile) { e.downloadedBytes = int64(tf.Length) } e.mu.Unlock() - log.Printf("resumed download: %d/%d pieces already done", len(resumedPieces), len(tf.PieceHashes)) + logger.Info("ENGINE", "resumed download: %d/%d pieces already done", len(resumedPieces), len(tf.PieceHashes)) } pieceWriter := newBufferedPieceWriter(partFile, tf.PieceLength, 8, 140*time.Millisecond) @@ -830,7 +877,7 @@ func (e *Engine) runDownload(ctx context.Context, tf *torrentfile.TorrentFile) { defer cleanupWorkers() e.setPhase("downloading") - log.Printf("download started: pieces=%d peers=%d", len(tf.PieceHashes), len(e.snapshotPeers())) + logger.Info("ENGINE", "download started: pieces=%d peers=%d", len(tf.PieceHashes), len(e.snapshotPeers())) lastProgressAt := time.Now() lastReannounceAt := time.Now() startWorkers(e.snapshotPeers()) @@ -881,31 +928,39 @@ func (e *Engine) runDownload(ctx context.Context, tf *torrentfile.TorrentFile) { startWorkers(e.snapshotPeers()) if time.Since(lastProgressAt) >= downloadStallTimeout { - log.Printf("download stalled: no progress for %s", downloadStallTimeout.Round(time.Second)) + logger.Info("ENGINE", "download stalled: no progress for %s", downloadStallTimeout.Round(time.Second)) e.setTerminalError("stalled", fmt.Errorf("download stalled: no piece progress for %s", downloadStallTimeout.Round(time.Second))) return } } } - - cleanupWorkers() if err := pieceWriter.Close(); err != nil { - log.Printf("failed to flush piece writer: %v", err) + logger.Info("ENGINE", "failed to flush piece writer: %v", err) e.setTerminalError("failed", fmt.Errorf("flush buffered pieces: %w", err)) return } e.setPhase("writing_files") if err := materializeDownloadedFiles(tf, partPath, e.outputRoot); err != nil { - log.Printf("failed to materialize files: %v", err) + logger.Info("ENGINE", "failed to materialize files: %v", err) e.setTerminalError("failed", fmt.Errorf("write output files: %w", err)) return } + // Smart Sorter + e.setPhase("sorting_files") + if newRoot, err := sorter.SortAndMove(e.outputRoot, tf.Name); err != nil { + logger.Warn("ENGINE", "Smart Sorter failed to move files: %v", err) + } else if newRoot != "" { + e.mu.Lock() + e.outputRoot = newRoot + e.mu.Unlock() + } + if err := os.Remove(partPath); err != nil && !errors.Is(err, os.ErrNotExist) { - log.Printf("failed to remove part file %s: %v", partPath, err) + logger.Info("ENGINE", "failed to remove part file %s: %v", partPath, err) } // Удаляем bitmap вместе с .part _ = os.Remove(partPath + ".bitmap") @@ -924,24 +979,25 @@ func (e *Engine) runDownload(ctx context.Context, tf *torrentfile.TorrentFile) { e.downloadSpeed = 0 e.uploadSpeed = 0 e.mu.Unlock() - log.Printf("download completed, transitioning to seeding: %s", e.outputPath) - + logger.Info("ENGINE", "download completed, transitioning to seeding: %s", e.outputPath) + e.runSeeding(ctx, tf) } func (e *Engine) runSeeding(ctx context.Context, tf *torrentfile.TorrentFile) { - log.Printf("entering seeding phase") - + logger.Info("ENGINE", "entering seeding phase") + pieceReader := storage.NewPieceReader(tf.Files, tf.PieceLength, tf.Length, e.outputRoot) + defer pieceReader.Close() workerCtx, workerCancel := context.WithCancel(ctx) defer workerCancel() - + var workersWG sync.WaitGroup startedWorkers := make(map[string]struct{}) workerDoneCh := make(chan string, 512) peerJobs := make(chan PeerStatus, maxPeerConnections*4) - + for i := 0; i < maxPeerConnections; i++ { workersWG.Add(1) go func() { @@ -964,7 +1020,7 @@ func (e *Engine) runSeeding(ctx context.Context, tf *torrentfile.TorrentFile) { } }() } - + startWorkers := func(peers []PeerStatus) { for _, peer := range peers { key := peerStatusKey(peer.Address, peer.Port) @@ -979,13 +1035,13 @@ func (e *Engine) runSeeding(ctx context.Context, tf *torrentfile.TorrentFile) { } } } - + heartbeatTicker := time.NewTicker(idleRetryDelay) defer heartbeatTicker.Stop() - + startWorkers(e.snapshotPeers()) lastReannounceAt := time.Now() - + for { select { case <-ctx.Done(): @@ -1060,11 +1116,11 @@ func (e *Engine) runSeedingWorker(ctx context.Context, tf *torrentfile.TorrentFi mask := byte(0xFF << (8 - lastPieces)) bitfield[len(bitfield)-1] &= mask } - + if err := pc.SendBitfield(ctx, bitfield); err != nil { return } - + if err := pc.SendUnchoke(ctx); err != nil { return } @@ -1073,39 +1129,49 @@ func (e *Engine) runSeedingWorker(ctx context.Context, tf *torrentfile.TorrentFi if err := ctx.Err(); err != nil { return } - - msg, err := pc.ReadMessage(ctx) + + msg, ptr, err := pc.ReadMessage(ctx) if err != nil { if isTimeout(err) { continue } return } - + switch msg.ID { case 6: // msgRequest if len(msg.Payload) < 12 { + if ptr != nil { + wireMsgPool.Put(ptr) + } continue } idx := int(binary.BigEndian.Uint32(msg.Payload[0:4])) begin := int(binary.BigEndian.Uint32(msg.Payload[4:8])) length := int(binary.BigEndian.Uint32(msg.Payload[8:12])) + if ptr != nil { + wireMsgPool.Put(ptr) + } + if length > 16384*2 { return } - + data, err := reader.ReadBlock(idx, begin, length) if err != nil { return } - + if err := pc.SendPiece(ctx, idx, begin, data); err != nil { return } - + e.addUploadedBytes(int64(length)) default: + if ptr != nil { + wireMsgPool.Put(ptr) + } } } } @@ -1123,14 +1189,14 @@ func (e *Engine) runPeerWorker( e.setPeerState(peer.Address, peer.Port, "connecting", "") peerAddr := net.JoinHostPort(peer.Address, strconv.Itoa(int(peer.Port))) - log.Printf("peer connection attempt: %s", peerAddr) + logger.Info("ENGINE", "peer connection attempt: %s", peerAddr) debugf("starting peer worker for %s", peerAddr) client, err := newPeerClient(ctx, peerAddr, tf.InfoHash, e.peerID, len(tf.PieceHashes)) if err != nil { if ctx.Err() != nil { e.setPeerState(peer.Address, peer.Port, "stopped", "") } else { - log.Printf("peer failed: %s err=%v", peerAddr, err) + logger.Info("ENGINE", "peer failed: %s err=%v", peerAddr, err) e.setPeerError(peer.Address, peer.Port, err.Error()) } return @@ -1162,11 +1228,14 @@ func (e *Engine) runPeerWorker( } }() - log.Printf("peer connected: %s", peerAddr) + logger.Info("ENGINE", "peer connected: %s", peerAddr) e.setPeerState(peer.Address, peer.Port, "ready", "") consecutiveFailures := 0 + endgameCancelCh := scheduler.SubscribeCancel(peerKey) + defer scheduler.UnsubscribeCancel(peerKey) + for { if ctx.Err() != nil { e.setPeerState(peer.Address, peer.Port, "stopped", "") @@ -1188,7 +1257,7 @@ func (e *Engine) runPeerWorker( if ctx.Err() != nil { e.setPeerState(peer.Address, peer.Port, "stopped", "") } else { - log.Printf("peer %s scheduler acquire failed: %v", peerAddr, err) + logger.Info("ENGINE", "peer %s scheduler acquire failed: %v", peerAddr, err) e.setPeerError(peer.Address, peer.Port, err.Error()) } return @@ -1210,10 +1279,31 @@ func (e *Engine) runPeerWorker( e.setPeerState(peer.Address, peer.Port, "requesting", "") pieceSize := pieceSizeForIndex(tf, task.Index) - pieceData, transferStats, err := client.DownloadPiece(ctx, task.Index, pieceSize) + + pieceCancelCh := make(chan struct{}) + pieceDoneCh := make(chan struct{}) + go func() { + for { + select { + case canceledPiece := <-endgameCancelCh: + if canceledPiece == task.Index { + close(pieceCancelCh) + return + } + case <-pieceDoneCh: + return + case <-ctx.Done(): + return + } + } + }() + + pieceData, transferStats, err := client.DownloadPiece(ctx, task.Index, pieceSize, pieceCancelCh) + close(pieceDoneCh) + if err != nil { if _, reportErr := scheduler.Report(ctx, task.Index, false); reportErr != nil && ctx.Err() == nil { - log.Printf("scheduler report failure for piece %d after peer error: %v", task.Index, reportErr) + logger.Info("ENGINE", "scheduler report failure for piece %d after peer error: %v", task.Index, reportErr) } e.setPieceState(task.Index, PieceMissing) if ctx.Err() != nil { @@ -1221,8 +1311,13 @@ func (e *Engine) runPeerWorker( return } + if errors.Is(err, ErrPieceCanceled) { + // Soft cancel: piece was downloaded by another peer, continue. + continue + } + consecutiveFailures++ - log.Printf("peer %s disconnected: %v", peerAddr, err) + logger.Info("ENGINE", "peer %s disconnected: %v", peerAddr, err) e.setPeerError(peer.Address, peer.Port, err.Error()) if shouldDropPeer(err) || consecutiveFailures >= 3 { return @@ -1233,11 +1328,11 @@ func (e *Engine) runPeerWorker( hash := sha1.Sum(pieceData) if hash != tf.PieceHashes[task.Index] { if _, reportErr := scheduler.Report(ctx, task.Index, false); reportErr != nil && ctx.Err() == nil { - log.Printf("scheduler report hash mismatch for piece %d failed: %v", task.Index, reportErr) + logger.Info("ENGINE", "scheduler report hash mismatch for piece %d failed: %v", task.Index, reportErr) } e.setPieceState(task.Index, PieceMissing) consecutiveFailures++ - log.Printf("peer %s piece %d hash mismatch", peerAddr, task.Index) + logger.Info("ENGINE", "peer %s piece %d hash mismatch", peerAddr, task.Index) e.setPeerError(peer.Address, peer.Port, fmt.Sprintf("piece %d hash mismatch", task.Index)) if consecutiveFailures >= 3 { return @@ -1247,10 +1342,10 @@ func (e *Engine) runPeerWorker( if err := pieceWriter.WritePiece(ctx, task.Index, pieceData); err != nil { if _, reportErr := scheduler.Report(ctx, task.Index, false); reportErr != nil && ctx.Err() == nil { - log.Printf("scheduler report write failure for piece %d failed: %v", task.Index, reportErr) + logger.Info("ENGINE", "scheduler report write failure for piece %d failed: %v", task.Index, reportErr) } e.setPieceState(task.Index, PieceMissing) - log.Printf("io error buffering piece %d from peer %s: %v", task.Index, peerAddr, err) + logger.Info("ENGINE", "io error buffering piece %d from peer %s: %v", task.Index, peerAddr, err) e.setPeerError(peer.Address, peer.Port, fmt.Sprintf("write piece %d: %v", task.Index, err)) return } @@ -1406,13 +1501,12 @@ func (e *Engine) refreshPeersFromTrackers(ctx context.Context, tf *torrentfile.T }) // Пересобираем индекс после сортировки e.peerIdx = buildPeerIdx(e.peers) - log.Printf("discovered %d new peers (total=%d)", added, len(e.peers)) + logger.Info("ENGINE", "discovered %d new peers (total=%d)", added, len(e.peers)) } return added } - func (e *Engine) currentDownloadedBytes() int64 { e.mu.RLock() defer e.mu.RUnlock() @@ -1467,7 +1561,6 @@ func (e *Engine) recordPieceComplete(address string, port uint16, pieceIndex int } } - // asyncSaveBitmap сохраняет bitmap в фоне (вызывается из runDownload heartbeat). func (e *Engine) asyncSaveBitmap(bitmapPath string) { e.mu.RLock() @@ -1482,7 +1575,6 @@ func (e *Engine) asyncSaveBitmap(bitmapPath string) { go func() { _ = saveBitmap(bitmapPath, completed, total) }() } - // peerByKey возвращает указатель на PeerStatus по ключу (O(1)). // Вызывать только под e.mu. func (e *Engine) peerByKey(key string) *PeerStatus { @@ -1673,7 +1765,6 @@ func buildPeerIdx(peers []PeerStatus) map[string]int { return idx } - func (e *Engine) updateSpeed(now time.Time) { e.mu.Lock() defer e.mu.Unlock() @@ -1866,11 +1957,11 @@ func createOrOpenPartFile(tf *torrentfile.TorrentFile, outputRoot string) (strin if bitmapPieces, bErr := loadBitmap(bitmapPath, len(tf.PieceHashes)); bErr == nil && len(bitmapPieces) > 0 { // Быстрый путь: bitmap есть, только верифицируем упомянутые куски completed = verifyPieces(existing, tf, bitmapPieces) - log.Printf("resume via bitmap: %d pieces verified", len(completed)) + logger.Info("ENGINE", "resume via bitmap: %d pieces verified", len(completed)) } else { // Медленный путь: сканируем все куски через SHA-1 completed = verifyAllPieces(existing, tf) - log.Printf("resume via full scan: %d/%d pieces verified", len(completed), len(tf.PieceHashes)) + logger.Info("ENGINE", "resume via full scan: %d/%d pieces verified", len(completed), len(tf.PieceHashes)) } // Перезаписываем bitmap актуальными данными _ = saveBitmap(bitmapPath, completed, len(tf.PieceHashes)) @@ -1964,8 +2055,6 @@ func loadBitmap(path string, expectedTotal int) ([]int, error) { return data.Completed, nil } - - func materializeDownloadedFiles(tf *torrentfile.TorrentFile, partPath, outputRoot string) error { partFile, err := os.Open(partPath) if err != nil { @@ -2071,10 +2160,14 @@ func (e *Engine) listenIncoming(ctx context.Context, tf *torrentfile.TorrentFile return } defer ln.Close() - log.Printf("listening for incoming peers on %s", ln.Addr()) + logger.Info("ENGINE", "listening for incoming peers on %s", ln.Addr()) + + // Open UPnP mapping + go e.portManager.OpenPort(ctx, 6881, "Ztorrent") go func() { <-ctx.Done() + e.portManager.ClosePort() ln.Close() }() @@ -2163,8 +2256,8 @@ func (e *Engine) handleIncoming(ctx context.Context, rawConn net.Conn, tf *torre } e.mu.Unlock() - log.Printf("incoming peer: %s", remoteAddr) - + logger.Info("ENGINE", "incoming peer: %s", remoteAddr) + // Delegate to the active loop via channel without closing select { case e.incomingConns <- rawConn: @@ -2178,13 +2271,13 @@ func (e *Engine) handleIncomingSeeding(ctx context.Context, tf *torrentfile.Torr defer conn.Close() // It was already handshaked, but we need a peerClient wrapper that bypasses the handshake. // Actually, I can use the newIncomingPeerClient I created in peerwire.go - + // Wait, peerwire.go has newIncomingPeerClient which STILL expects to do sendExtendedHandshake and readInitialMessages. - // Yes, that's correct. + // Yes, that's correct. // Extensions bit is in the handshake, but wait! We didn't save the extensions bit from their handshake! // In handleIncoming, we didn't check their extensions bit. // Let's assume extensions=true for now, or just false. We don't strictly need extensions for seeding basic pieces. - + pc, err := newIncomingPeerClient(ctx, conn, false, len(tf.PieceHashes)) if err != nil { return @@ -2207,11 +2300,11 @@ func (e *Engine) handleIncomingSeeding(ctx context.Context, tf *torrentfile.Torr mask := byte(0xFF << (8 - lastPieces)) bitfield[len(bitfield)-1] &= mask } - + if err := pc.SendBitfield(ctx, bitfield); err != nil { return } - + if err := pc.SendUnchoke(ctx); err != nil { return } @@ -2220,38 +2313,49 @@ func (e *Engine) handleIncomingSeeding(ctx context.Context, tf *torrentfile.Torr if err := ctx.Err(); err != nil { return } - - msg, err := pc.ReadMessage(ctx) + + msg, ptr, err := pc.ReadMessage(ctx) if err != nil { if isTimeout(err) { continue } return } - + switch msg.ID { case 6: // msgRequest if len(msg.Payload) < 12 { + if ptr != nil { + wireMsgPool.Put(ptr) + } continue } idx := int(binary.BigEndian.Uint32(msg.Payload[0:4])) begin := int(binary.BigEndian.Uint32(msg.Payload[4:8])) length := int(binary.BigEndian.Uint32(msg.Payload[8:12])) - + + if ptr != nil { + wireMsgPool.Put(ptr) + } + if length > 16384*2 { return } - + data, err := reader.ReadBlock(idx, begin, length) if err != nil { return } - + if err := pc.SendPiece(ctx, idx, begin, data); err != nil { return } - + e.addUploadedBytes(int64(length)) + default: + if ptr != nil { + wireMsgPool.Put(ptr) + } } } }() @@ -2268,7 +2372,7 @@ func (e *Engine) SetSequentialMode(mode bool) { } // HasPiece is a thread-safe check to see if a piece is fully downloaded -func (e *Engine) HasPiece(index int) bool { +func (e *Engine) HasCompletedPiece(index int) bool { e.mu.RLock() defer e.mu.RUnlock() if index < 0 || index >= len(e.pieceStates) { @@ -2277,6 +2381,22 @@ func (e *Engine) HasPiece(index int) bool { return e.pieceStates[index] == PieceCompleted } +// ToggleFilePriority toggles a file's priority between 0 (skip) and 1 (normal). +func (e *Engine) ToggleFilePriority(fileIdx int) { + e.mu.Lock() + defer e.mu.Unlock() + + if e.torrent == nil || fileIdx < 0 || fileIdx >= len(e.torrent.Files) { + return + } + + if e.torrent.Files[fileIdx].Priority == 0 { + e.torrent.Files[fileIdx].Priority = 1 + } else { + e.torrent.Files[fileIdx].Priority = 0 + } +} + // PartFilePath returns the path to the current .part file func (e *Engine) PartFilePath() string { e.mu.RLock() diff --git a/internal/torrent/peerwire.go b/internal/torrent/peerwire.go index 8240059..47b0a0c 100644 --- a/internal/torrent/peerwire.go +++ b/internal/torrent/peerwire.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "io" - "log" "net" "sync" "sync/atomic" @@ -16,6 +15,7 @@ import ( "github.com/jackpal/bencode-go" "golang.org/x/time/rate" + "github.com/veggiedefender/torrent-client/internal/logger" "github.com/veggiedefender/torrent-client/internal/torrentfile" "github.com/veggiedefender/torrent-client/internal/tracker" ) @@ -38,8 +38,8 @@ const ( requestBlockSize = 16 * 1024 // Adaptive pipeline bounds - minPipelineDepth = 4 - maxPipelineDepth = 64 + minPipelineDepth = 4 + maxPipelineDepth = 64 initPipelineDepth = 8 keepaliveInterval = 90 * time.Second @@ -52,6 +52,14 @@ const ( peerSocketWriteBuffer = 512 * 1024 ) +var wireMsgPool = sync.Pool{ + New: func() any { + // typical piece msg: 4(len) + 1(id) + 8(header) + 16384(block) = 16397 + b := make([]byte, requestBlockSize+128) + return &b + }, +} + type extendedHandshake struct { M map[string]int `bencode:"m"` MetadataSize int `bencode:"metadata_size"` @@ -174,8 +182,8 @@ func min(a, b int) int { } type peerClient struct { - conn *measuredConn - rlConn *rateLimitedConn + conn *measuredConn + rlConn *rateLimitedConn have []bool hasPieceInfo bool @@ -192,15 +200,14 @@ type peerClient struct { kaOnce sync.Once } - func newPeerClient(ctx context.Context, addr string, infoHash [20]byte, peerID [20]byte, pieceCount int) (*peerClient, error) { dialer := net.Dialer{Timeout: peerConnectTimeout} rawConn, err := dialer.DialContext(ctx, "tcp", addr) if err != nil { - log.Printf("peer dial failed %s: %v", addr, err) + logger.Info("PEER", "peer dial failed %s: %v", addr, err) return nil, err } - log.Printf("connected to peer %s", addr) + logger.Info("PEER", "connected to peer %s", addr) if tcpConn, ok := rawConn.(*net.TCPConn); ok { _ = tcpConn.SetNoDelay(true) @@ -229,7 +236,7 @@ func newPeerClient(ctx context.Context, addr string, infoHash [20]byte, peerID [ if pc.supportsExtensions { if err := pc.sendExtendedHandshake(ctx); err != nil { - log.Printf("failed to send extended handshake to %s: %v", addr, err) + logger.Info("PEER", "failed to send extended handshake to %s: %v", addr, err) } } @@ -240,7 +247,7 @@ func newPeerClient(ctx context.Context, addr string, infoHash [20]byte, peerID [ debugf("sent interested to %s", addr) if err := pc.readInitialMessages(ctx); err != nil { - log.Printf("peer %s initial message read failed: %v", addr, err) + logger.Info("PEER", "peer %s initial message read failed: %v", addr, err) measured.Close() return nil, err } @@ -249,7 +256,6 @@ func newPeerClient(ctx context.Context, addr string, infoHash [20]byte, peerID [ return pc, nil } - func (pc *peerClient) PeerUtPexID() int { return pc.peerUtPexID } @@ -268,7 +274,7 @@ func newIncomingPeerClient(ctx context.Context, rawConn net.Conn, extensions boo if pc.supportsExtensions { if err := pc.sendExtendedHandshake(ctx); err != nil { - log.Printf("failed to send extended handshake to incoming peer: %v", err) + logger.Info("PEER", "failed to send extended handshake to incoming peer: %v", err) } } @@ -308,14 +314,15 @@ func (pc *peerClient) keepaliveLoop() { } } - 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, pieceTransferStats, error) { +var ErrPieceCanceled = errors.New("piece download canceled") + +func (pc *peerClient) DownloadPiece(ctx context.Context, pieceIndex int, pieceLength int, cancelCh <-chan struct{}) ([]byte, pieceTransferStats, error) { var transfer pieceTransferStats if pieceLength <= 0 { return nil, transfer, fmt.Errorf("invalid piece length %d", pieceLength) @@ -342,6 +349,17 @@ func (pc *peerClient) DownloadPiece(ctx context.Context, pieceIndex int, pieceLe depth := initPipelineDepth // adaptive, пересчитывается каждые 4 блока for received < pieceLength { + if cancelCh != nil { + select { + case <-cancelCh: + for begin, req := range pending { + pc.SendCancel(pieceIndex, begin, req.length) + } + return nil, transfer, ErrPieceCanceled + default: + } + } + for len(pending) < depth && offset < pieceLength { blockLength := requestBlockSize if remaining := pieceLength - offset; remaining < blockLength { @@ -358,22 +376,34 @@ func (pc *peerClient) DownloadPiece(ctx context.Context, pieceIndex int, pieceLe offset += blockLength } - gotIndex, gotBegin, block, err := pc.readPieceMessage(ctx) + gotIndex, gotBegin, block, bufPtr, err := pc.readPieceMessage(ctx) if err != nil { return nil, transfer, err } if gotIndex != pieceIndex { + if bufPtr != nil { + wireMsgPool.Put(bufPtr) + } continue } req, ok := pending[gotBegin] if !ok { + if bufPtr != nil { + wireMsgPool.Put(bufPtr) + } continue } if len(block) != req.length { + if bufPtr != nil { + wireMsgPool.Put(bufPtr) + } return nil, transfer, fmt.Errorf("unexpected block size for piece=%d begin=%d: got=%d want=%d", pieceIndex, gotBegin, len(block), req.length) } if gotBegin < 0 || gotBegin+len(block) > len(piece) { + if bufPtr != nil { + wireMsgPool.Put(bufPtr) + } return nil, transfer, fmt.Errorf("piece block bounds invalid for piece=%d begin=%d block=%d", pieceIndex, gotBegin, len(block)) } @@ -381,6 +411,10 @@ func (pc *peerClient) DownloadPiece(ctx context.Context, pieceIndex int, pieceLe delete(pending, gotBegin) received += len(block) + if bufPtr != nil { + wireMsgPool.Put(bufPtr) + } + blocksCompleted++ blockLatency := time.Since(req.requestedAt) latencySum += blockLatency @@ -438,7 +472,6 @@ func (pc *peerClient) sendMessageDirect(msgID int, payload []byte) error { return err } - func (pc *peerClient) readInitialMessages(ctx context.Context) error { if err := pc.setReadDeadlineFromContext(ctx, 2*time.Second); err != nil { return err @@ -446,7 +479,7 @@ func (pc *peerClient) readInitialMessages(ctx context.Context) error { defer pc.conn.SetReadDeadline(time.Time{}) for { - msg, err := readWireMessage(pc.conn) + msg, bufPtr, err := readWireMessage(pc.conn) if err != nil { if isTimeout(err) { debugf("peer initial message window finished") @@ -455,6 +488,9 @@ func (pc *peerClient) readInitialMessages(ctx context.Context) error { return err } pc.consumeMessage(msg) + if bufPtr != nil { + wireMsgPool.Put(bufPtr) + } } } @@ -475,7 +511,7 @@ func (pc *peerClient) waitForUnchoke(ctx context.Context) error { if err := pc.setReadDeadlineFromContext(ctx, peerReadTimeout); err != nil { return err } - msg, err := readWireMessage(pc.conn) + msg, bufPtr, err := readWireMessage(pc.conn) if err != nil { if isTimeout(err) { continue @@ -483,7 +519,12 @@ func (pc *peerClient) waitForUnchoke(ctx context.Context) error { return err } pc.consumeMessage(msg) - if msg.ID == msgUnchoke { + msgID := msg.ID + if bufPtr != nil { + wireMsgPool.Put(bufPtr) + } + + if msgID == msgUnchoke { debugf("peer unchoked") return nil } @@ -499,41 +540,53 @@ func (pc *peerClient) sendRequest(ctx context.Context, pieceIndex, begin, length return pc.sendMessage(ctx, msgRequest, payload) } -func (pc *peerClient) readPieceMessage(ctx context.Context) (pieceIndex int, begin int, block []byte, err error) { +func (pc *peerClient) readPieceMessage(ctx context.Context) (pieceIndex int, begin int, block []byte, bufPtr *[]byte, err error) { for { if err := ctx.Err(); err != nil { - return 0, 0, nil, err + return 0, 0, nil, nil, err } if err := pc.setReadDeadlineFromContext(ctx, peerReadTimeout); err != nil { - return 0, 0, nil, err + return 0, 0, nil, nil, err } - msg, err := readWireMessage(pc.conn) + msg, ptr, err := readWireMessage(pc.conn) if err != nil { if isTimeout(err) { continue } - return 0, 0, nil, err + return 0, 0, nil, nil, err } switch msg.ID { case msgPiece: if len(msg.Payload) < 8 { + if ptr != nil { + wireMsgPool.Put(ptr) + } continue } gotIndex := int(binary.BigEndian.Uint32(msg.Payload[0:4])) gotBegin := int(binary.BigEndian.Uint32(msg.Payload[4:8])) data := msg.Payload[8:] if len(data) == 0 { + if ptr != nil { + wireMsgPool.Put(ptr) + } continue } debugf("received piece %d offset %d block=%d", gotIndex, gotBegin, len(data)) - return gotIndex, gotBegin, data, nil + return gotIndex, gotBegin, data, ptr, nil case msgChoke: pc.consumeMessage(msg) - return 0, 0, nil, errors.New("peer choked") + if ptr != nil { + wireMsgPool.Put(ptr) + } + return 0, 0, nil, nil, errors.New("peer choked") default: pc.consumeMessage(msg) + if ptr != nil { + wireMsgPool.Put(ptr) + } } } } @@ -724,9 +777,9 @@ func (pc *peerClient) SendPex(ctx context.Context, added []tracker.Peer) error { return pc.sendMessage(ctx, msgExtended, buf.Bytes()) } -func (pc *peerClient) ReadMessage(ctx context.Context) (wireMessage, error) { +func (pc *peerClient) ReadMessage(ctx context.Context) (wireMessage, *[]byte, error) { if err := pc.setReadDeadlineFromContext(ctx, peerReadTimeout); err != nil { - return wireMessage{}, err + return wireMessage{}, nil, err } return readWireMessage(pc.conn) } @@ -737,34 +790,58 @@ func (pc *peerClient) sendMessage(ctx context.Context, msgID int, payload []byte } length := uint32(1 + len(payload)) - buf := make([]byte, 4+length) + + // Get buffer from pool + bufPtr := wireMsgPool.Get().(*[]byte) + buf := *bufPtr + if uint32(len(buf)) < 4+length { + // Fallback to allocation if payload is larger than typical + buf = make([]byte, 4+length) + } + binary.BigEndian.PutUint32(buf[0:4], length) buf[4] = byte(msgID) copy(buf[5:], payload) - _, err := pc.conn.Write(buf) + _, err := pc.conn.Write(buf[:4+length]) + + // Return buffer to pool + wireMsgPool.Put(bufPtr) + return err } -func readWireMessage(r io.Reader) (wireMessage, error) { +func readWireMessage(r io.Reader) (wireMessage, *[]byte, error) { var lengthBuf [4]byte if _, err := io.ReadFull(r, lengthBuf[:]); err != nil { - return wireMessage{}, err + return wireMessage{}, nil, err } length := binary.BigEndian.Uint32(lengthBuf[:]) if length == 0 { - return wireMessage{ID: -1}, nil + return wireMessage{ID: -1}, nil, nil } if length > maxWireMessageSize { - return wireMessage{}, fmt.Errorf("wire message too large: %d", length) + return wireMessage{}, nil, fmt.Errorf("wire message too large: %d", length) } - msg := make([]byte, length) - if _, err := io.ReadFull(r, msg); err != nil { - return wireMessage{}, err + var msg []byte + var bufPtr *[]byte + + if length <= requestBlockSize+128 { + bufPtr = wireMsgPool.Get().(*[]byte) + msg = (*bufPtr)[:length] + } else { + msg = make([]byte, length) } - return wireMessage{ID: int(msg[0]), Payload: msg[1:]}, nil + + if _, err := io.ReadFull(r, msg); err != nil { + if bufPtr != nil { + wireMsgPool.Put(bufPtr) + } + return wireMessage{}, nil, err + } + return wireMessage{ID: int(msg[0]), Payload: msg[1:]}, bufPtr, nil } func bitfieldHasPiece(bitfield []byte, index int) bool { @@ -822,7 +899,7 @@ func (pc *peerClient) ReadMetadataMessage(ctx context.Context) (piece int, data if err := pc.setReadDeadlineFromContext(ctx, peerReadTimeout); err != nil { return 0, nil, false, err } - msg, err := readWireMessage(pc.conn) + msg, ptr, err := readWireMessage(pc.conn) if err != nil { if isTimeout(err) { continue @@ -831,6 +908,9 @@ func (pc *peerClient) ReadMetadataMessage(ctx context.Context) (piece int, data } if msg.ID == msgExtended { if len(msg.Payload) == 0 { + if ptr != nil { + wireMsgPool.Put(ptr) + } continue } extID := int(msg.Payload[0]) @@ -838,20 +918,34 @@ func (pc *peerClient) ReadMetadataMessage(ctx context.Context) (piece int, data reader := bytes.NewReader(msg.Payload[1:]) var dict map[string]int if err := bencode.Unmarshal(reader, &dict); err != nil { + if ptr != nil { + wireMsgPool.Put(ptr) + } continue } msgType, ok := dict["msg_type"] if !ok { + if ptr != nil { + wireMsgPool.Put(ptr) + } continue } pieceIdx := dict["piece"] - + if msgType == 2 { + if ptr != nil { + wireMsgPool.Put(ptr) + } return pieceIdx, nil, true, nil } if msgType == 1 { bytesRead := len(msg.Payload[1:]) - reader.Len() - data := msg.Payload[1+bytesRead:] + srcData := msg.Payload[1+bytesRead:] + data := make([]byte, len(srcData)) + copy(data, srcData) + if ptr != nil { + wireMsgPool.Put(ptr) + } return pieceIdx, data, false, nil } } else if extID == 0 { @@ -860,6 +954,10 @@ func (pc *peerClient) ReadMetadataMessage(ctx context.Context) (piece int, data } else { pc.consumeMessage(msg) } + + if ptr != nil { + wireMsgPool.Put(ptr) + } } } diff --git a/internal/torrentfile/torrentfile.go b/internal/torrentfile/torrentfile.go index 19fe9d1..168be30 100644 --- a/internal/torrentfile/torrentfile.go +++ b/internal/torrentfile/torrentfile.go @@ -31,8 +31,9 @@ type file struct { } type File struct { - Path string - Length int + Path string + Length int + Priority int // 0 = skip, 1 = normal } type TorrentFile struct { @@ -198,7 +199,7 @@ func splitPieceHashes(rawPieces string) ([][20]byte, error) { func deriveFilesAndLength(info bencodeInfo) ([]File, int, error) { if info.Length > 0 { - return []File{{Path: info.Name, Length: info.Length}}, info.Length, nil + return []File{{Path: info.Name, Length: info.Length, Priority: 1}}, info.Length, nil } files := make([]File, 0, len(info.Files)) @@ -208,20 +209,18 @@ func deriveFilesAndLength(info bencodeInfo) ([]File, int, error) { return nil, 0, errors.New("torrent file length must be greater than zero") } if len(f.Path) == 0 { - return nil, 0, errors.New("torrent file path is empty") + return nil, 0, errors.New("torrent file path cannot be empty") } parts := make([]string, 0, len(f.Path)+1) parts = append(parts, info.Name) parts = append(parts, f.Path...) filePath := path.Join(parts...) - if filePath == "." { - return nil, 0, errors.New("torrent file path is invalid") - } - + files = append(files, File{ - Path: filePath, - Length: f.Length, + Path: filePath, + Length: f.Length, + Priority: 1, }) totalLength += f.Length } diff --git a/internal/tracker/tracker.go b/internal/tracker/tracker.go index 62de82a..bbd7abc 100644 --- a/internal/tracker/tracker.go +++ b/internal/tracker/tracker.go @@ -153,47 +153,77 @@ func getPeersUDP(ctx context.Context, announceURL *url.URL, infoHash [20]byte, l if err != nil { return nil, err } - connectReq := buildUDPConnectRequest(connectTx) - if _, err := conn.Write(connectReq[:]); err != nil { - return nil, err - } - resp := make([]byte, 65535) - n, err := conn.Read(resp) - if err != nil { - return nil, err + var connectionID uint64 + var connOK bool + for n := 0; n <= 8; n++ { + if _, err := conn.Write(connectReq[:]); err != nil { + return nil, err + } + + conn.SetReadDeadline(time.Now().Add(15 * time.Second * time.Duration(1< 0; i++ { + targetID := e.TargetNodeQueue[0] + e.TargetNodeQueue = e.TargetNodeQueue[1:] + e.spawnDownloadParticleTo(e.Nodes[targetID]) + } + activeDL := 0 + for _, p := range e.Particles { + if p.Type == logoTypeDownload && p.Active { + activeDL++ + } + } + if len(e.TargetNodeQueue) == 0 && activeDL == 0 { + e.Phase = logoPhaseVerify + e.PhaseTime = 0 + for _, n := range e.Nodes { + if !n.IsOutline { + n.setState(logoStateVerifying, now) + } + } + } + case logoPhaseVerify: + if e.PhaseTime >= 3.0 { + e.Phase = logoPhaseDisperse + e.PhaseTime = 0 + for _, n := range e.Nodes { + if !n.IsOutline { + n.setState(logoStateCompleted, now) + e.spawnUploadParticle(n) + } + } + } + case logoPhaseDisperse: + if e.PhaseTime >= 1.0 { + for _, n := range e.Nodes { + if !n.IsOutline { + n.setState(logoStateFading, now) + } + } + e.Phase = logoPhaseWait + e.PhaseTime = 0 + } + case logoPhaseWait: + if e.PhaseTime >= 2.0 { + e.startAssemblePhase(now) + } + } + activeParticles := e.Particles[:0] + for _, p := range e.Particles { + p.Update(dt) + if !p.Active && p.Type == logoTypeDownload { + if p.TargetID >= 0 && p.TargetID < len(e.Nodes) { + node := e.Nodes[p.TargetID] + if node.State == logoStateIdle { + node.setState(logoStateDownloading, now) + } + } + } + if p.Active { + activeParticles = append(activeParticles, p) + } + } + e.Particles = activeParticles +} + +func (e *LogoEngine) renderNow(now time.Time) string { + e.ensureGrid(e.Width, e.Height) + for y := 0; y < e.Height; y++ { + for x := 0; x < e.Width; x++ { + e.Grid[y][x] = " " + } + } + for _, n := range e.Nodes { + if !n.IsOutline { + x, y := int(n.X+0.5), int(n.Y+0.5) + if x >= 0 && x < e.Width && y >= 0 && y < e.Height { + char, style := n.Render(now) + e.Grid[y][x] = style.Render(char) + } + } + } + for _, p := range e.Particles { + x, y := int(p.X+0.5), int(p.Y+0.5) + if x >= 0 && x < e.Width && y >= 0 && y < e.Height { + char, style := p.Render() + e.Grid[y][x] = style.Render(char) + } + } + for _, n := range e.Nodes { + if n.IsOutline { + x, y := int(n.X+0.5), int(n.Y+0.5) + if x >= 0 && x < e.Width && y >= 0 && y < e.Height { + char, style := n.Render(now) + e.Grid[y][x] = style.Render(char) + } + } + } + var sb strings.Builder + sb.Grow(e.Height * e.Width * 5) + for y := 0; y < e.Height; y++ { + sb.WriteString(strings.Join(e.Grid[y][:e.Width], "")) + if y < e.Height-1 { + sb.WriteString("\n") + } + } + return sb.String() +} + + func (e *LogoEngine) startAssemblePhase(now time.Time) { e.Phase = logoPhaseAssemble e.PhaseTime = 0 @@ -257,96 +406,11 @@ func (e *LogoEngine) startAssemblePhase(now time.Time) { }) } +// Update переключает кадр — нулевой CPU. func (e *LogoEngine) Update(now time.Time) { - if e.LastUpdate.IsZero() { - e.LastUpdate = now + if len(e.frames) > 0 { + e.frameIndex = (e.frameIndex + 1) % len(e.frames) } - 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) { @@ -371,51 +435,12 @@ func (e *LogoEngine) spawnUploadParticle(n *logoNode) { e.Particles = append(e.Particles, NewLogoParticle(logoTypeUpload, n.X, n.Y, targetX, targetY, -1)) } +// Render возвращает pre-baked кадр — нулевой CPU. func (e *LogoEngine) Render(now time.Time) string { - 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] = " " - } + if len(e.frames) == 0 { + return "" } - - 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() + return e.frames[e.frameIndex] } // generateZNodes генерирует координаты для Z-образного логотипа. diff --git a/ui/matrix.go b/ui/matrix.go new file mode 100644 index 0000000..69fddd5 --- /dev/null +++ b/ui/matrix.go @@ -0,0 +1,175 @@ +package ui + +import ( + "fmt" + "math/rand" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +type matrixDrop struct { + X int + Y float64 + Speed float64 + Length int + CharType int // 0 = Z, 1 = V, 2 = O +} + +// MatrixEngine рендерит кадры заранее и хранит их как 2D-сетки ячеек. +// RenderRegion вырезает любой прямоугольник напрямую — без ANSI-парсинга. +type MatrixEngine struct { + Width int + Height int + + // frames[f] — плоский массив len=Width*Height, доступ: cell = frames[f][y*Width+x] + frames [][]string + frameIndex int +} + +const matrixNumFrames = 120 + +func NewMatrixEngine(width, height int) *MatrixEngine { + e := &MatrixEngine{ + Width: width, + Height: height, + } + e.bakeFrames() + return e +} + +func (e *MatrixEngine) bakeFrames() { + drops := []*matrixDrop{} + + step := func(dt float64) { + for _, d := range drops { + d.Y += d.Speed * dt + } + var active []*matrixDrop + for _, d := range drops { + if int(d.Y)-d.Length < e.Height { + active = append(active, d) + } + } + drops = active + targetDrops := e.Width / 6 + if targetDrops < 1 { + targetDrops = 1 + } + if len(drops) < targetDrops && rand.Float64() < 0.15 { + drops = append(drops, &matrixDrop{ + X: rand.Intn(e.Width), + Y: float64(rand.Intn(5)) - 5.0, + Speed: 3.0 + rand.Float64()*4.0, + Length: 4 + rand.Intn(5), + CharType: rand.Intn(3), + }) + } + } + + renderGrid := func() []string { + grid := make([]string, e.Width*e.Height) + for i := range grid { + grid[i] = " " + } + for _, d := range drops { + headY := int(d.Y) + for i := 0; i < d.Length; i++ { + y := headY - i + x := d.X + if y >= 0 && y < e.Height && x >= 0 && x < e.Width { + opacity := 1.0 - float64(i)/float64(d.Length) + var r, g, b int + if i == 0 { + r, g, b = 255, 255, 255 + } else if d.CharType == 0 { + g = int(255.0 * opacity) + } else if d.CharType == 1 { + r = int(255.0 * opacity) + } else { + r = int(255.0 * opacity) + g = int(255.0 * opacity) + } + char := "Z" + if d.CharType == 1 { + char = "V" + } else if d.CharType == 2 { + char = "O" + } + grid[y*e.Width+x] = getMatrixStyledChar(char, r, g, b, i == 0) + } + } + } + return grid + } + + // Прогрев: заполняем экран каплями + for i := 0; i < 100; i++ { + step(0.1) + } + + e.frames = make([][]string, matrixNumFrames) + for f := 0; f < matrixNumFrames; f++ { + // 5 шагов симуляции на кадр (100ms / 5 = 20ms шаг) + for s := 0; s < 5; s++ { + step(0.02) + } + e.frames[f] = renderGrid() + } +} + +// Update переключает кадр — нулевой CPU. +func (e *MatrixEngine) Update() { + e.frameIndex = (e.frameIndex + 1) % len(e.frames) +} + +// RenderRegion вырезает прямоугольник [rx,ry,rw,rh] из текущего кадра. +// Работает напрямую с ячейками — нет ANSI-парсинга, нет аллокаций. +func (e *MatrixEngine) RenderRegion(rx, ry, rw, rh int) string { + if len(e.frames) == 0 || rw <= 0 || rh <= 0 { + return strings.Repeat(" \n", rh) + } + frame := e.frames[e.frameIndex] + var sb strings.Builder + sb.Grow(rh * rw * 8) + for row := 0; row < rh; row++ { + y := ry + row + for col := 0; col < rw; col++ { + x := rx + col + if y >= 0 && y < e.Height && x >= 0 && x < e.Width { + sb.WriteString(frame[y*e.Width+x]) + } else { + sb.WriteString(" ") + } + } + if row < rh-1 { + sb.WriteString("\n") + } + } + return sb.String() +} + +// CurrentFrame — весь кадр целиком (для совместимости). +func (e *MatrixEngine) CurrentFrame() string { + return e.RenderRegion(0, 0, e.Width, e.Height) +} + +var matrixStyleCache = make(map[string]string) + +func getMatrixStyledChar(char string, r, g, b int, bold bool) string { + hex := fmt.Sprintf("#%02x%02x%02x", r, g, b) + key := char + hex + if bold { + key += "B" + } + if val, ok := matrixStyleCache[key]; ok { + return val + } + style := lipgloss.NewStyle().Foreground(lipgloss.Color(hex)) + if bold { + style = style.Bold(true) + } + res := style.Render(char) + matrixStyleCache[key] = res + return res +} diff --git a/ui/styles.go b/ui/styles.go index 9efbc89..e3318b4 100644 --- a/ui/styles.go +++ b/ui/styles.go @@ -1,6 +1,10 @@ package ui -import "github.com/charmbracelet/lipgloss" +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) // Color definitions (Hex palette) const ( @@ -14,6 +18,7 @@ const ( ColorGreen = "#3cd58c" // Vibrant green (completed) ColorYellow = "#f4b942" // Amber/yellow (downloading) ColorRed = "#ff5555" // Bright red (error/missing) + ColorBlue = "#00aeff" // Vibrant blue (dht) ) var ( @@ -126,7 +131,135 @@ var ( Bold(true) // Grid colors - StylePieceCompleted = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)) + StylePieceCompleted = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)) StylePieceDownloading = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorYellow)) - StylePieceMissing = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorBorder)) + StylePieceMissing = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorBorder)) + + // ── Кэшированные стили для горячих путей рендеринга ────────────────────── + + // Dashboard header + StyleHeaderBrand = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Bold(true) + StyleHeaderSep = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorBorder)) + StyleHeaderName = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText)) + StyleHeaderIcon = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)) + StyleHeaderLine = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, false, true, false). + BorderForeground(lipgloss.Color(ColorBorder)). + PaddingBottom(0) + + // Phase status styles + StylePhaseStopped = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)).Bold(true) + StylePhaseFailed = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorRed)).Bold(true) + StylePhaseSeeding = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)).Bold(true) + StylePhaseDownload = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)).Bold(true) + StylePhaseDefault = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorYellow)).Bold(true) + + // KPI cards + StyleKPIProgress = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan)) + StyleKPIDown = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorGreen)) + StyleKPIUp = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorYellow)) + StyleKPIEta = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorPink)) + StyleKPIPeers = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorText)) + StyleKPIBarFill = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)) + StyleKPIBarEmpty = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorBorder)) + + // Menu styles + StyleMenuText = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText)) + StyleMenuDesc = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)) + StyleMenuActive = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)).Bold(true) + StyleMenuCursor = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)) + StyleMenuHelp = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)).MarginTop(2) + StyleMenuBorder = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorBorder)) + StyleMenuPadding = lipgloss.NewStyle().Padding(2, 4) + StyleMenuBox = lipgloss.NewStyle().Padding(2, 0) + + // Log overlay styles + StyleLogTime = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)) + StyleLogEngine = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Bold(true) + StyleLogDHT = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorBlue)).Bold(true) + StyleLogPeer = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)).Bold(true) + StyleLogSys = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)).Bold(true) + StyleLogDefault = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText)).Bold(true) + StyleLogError = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorRed)).Bold(true) + StyleLogWarn = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorYellow)).Bold(true) + StyleLogInfo = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)) + StyleLogDebug = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)) + StyleLogMsg = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText)) + StyleLogMsgError = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorRed)) + + // Overview styles + StyleSectionTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan)) + StyleDim = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)) + StyleVal = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText)) + StyleSpeedDown = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)).Bold(true) + StyleSpeedUp = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorYellow)).Bold(true) + StyleETA = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Bold(true) + StyleErrText = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorRed)).Bold(true) + + // HelpBar + StyleHelpKey = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Bold(true) + StyleHelpDesc = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)) + StyleHelpSep = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorBorder)) + StyleHelpBar = lipgloss.NewStyle(). + Foreground(lipgloss.Color(ColorSubdued)). + Border(lipgloss.NormalBorder(), true, false, false, false). + BorderForeground(lipgloss.Color(ColorBorder)). + MarginTop(1). + PaddingTop(0) + + // Logo + StyleLogoTorrent = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText)).Bold(true) + StyleLogoZ = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)).Bold(true) + + // Settings indicator + StyleSettingsCursor = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)) + StyleSettingsActive = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)).Bold(true) + StyleSettingsLabel = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)).Width(26) + + // Additional menu + StyleAdditCyan = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan)) + StyleAdditPink = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Bold(true) + StyleAdditVal = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText)) + StyleAdditShort = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorYellow)) +) + +// Статичные строки, вычисляемые один раз +var ( + // Статичный "torrent" ASCII-арт (не меняется никогда) + cachedTorrentArt = StyleLogoTorrent.Render(strings.Join([]string{ + "████████╗ ██████╗ ██████╗ ██████╗ ███████╗███╗ ██╗████████╗", + "╚══██╔══╝██╔═══██╗██╔══██╗██╔══██╗██╔════╝████╗ ██║╚══██╔══╝", + " ██║ ██║ ██║██████╔╝██████╔╝█████╗ ██╔██╗ ██║ ██║ ", + " ██║ ██║ ██║██╔══██╗██╔══██╗██╔══╝ ██║╚████║ ██║ ", + " ██║ ╚██████╔╝██║ ██║██║ ██║███████╗██║ ╚███║ ██║ ", + " ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚══╝ ╚═╝ ", + }, "\n")) + + // Статичный Z-логотип (при отключённых анимациях) + cachedStaticZArt = StyleLogoZ.Render(strings.Join([]string{ + " ", + " ", + "███████╗", + "╚════██║", + " ██╔╝", + " ██╔╝ ", + " ██╔╝ ", + " ███████╗", + " ╚══════╝", + " ", + " ", + " ", + }, "\n")) + + // Статичный help bar главного меню + cachedMenuHelp = StyleMenuHelp.Render(" ↓↑ — выбор Enter — подтвердить Q — выход") + + // Статичные строки рамки меню (зависят от boxWidth=85, не меняются) + cachedMenuTopBorder = StyleMenuBorder.Render("╭" + strings.Repeat("─", 85) + "╮") + cachedMenuBottomBorder = StyleMenuBorder.Render("╰" + strings.Repeat("─", 85) + "╯") + cachedMenuLeftEdge = StyleMenuBorder.Render("│") + cachedMenuRightEdge = StyleMenuBorder.Render("│") + + // Help bar dashboard + cachedHelpSep = StyleHelpSep.Render(" │ ") ) diff --git a/ui/ui.go b/ui/ui.go index 5ed05c7..e792040 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -19,8 +19,9 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" appcore "github.com/veggiedefender/torrent-client/internal/app" + "github.com/veggiedefender/torrent-client/internal/config" "github.com/veggiedefender/torrent-client/internal/history" - "github.com/veggiedefender/torrent-client/internal/sessionlog" + "github.com/veggiedefender/torrent-client/internal/logger" "github.com/veggiedefender/torrent-client/internal/torrent" "github.com/veggiedefender/torrent-client/internal/version" ) @@ -51,10 +52,8 @@ const ( // ── Раскладка клавиш ────────────────────────────────────────────────────────── type keyMap struct { - TabOverview key.Binding - TabPeers key.Binding - TabFiles key.Binding - TabTrackers key.Binding + TabOverview key.Binding + TabNetwork key.Binding TabJournal key.Binding TabLogs key.Binding TabAbout key.Binding @@ -65,19 +64,18 @@ type keyMap struct { LogsDelete key.Binding LogsBack key.Binding NextTab key.Binding - PrevTab key.Binding - PauseResume key.Binding - OpenNew key.Binding - Clear key.Binding - Quit key.Binding - FilePicker key.Binding + PrevTab key.Binding + PauseResume key.Binding + OpenNew key.Binding + Clear key.Binding + Quit key.Binding + ToggleFile key.Binding + FilePicker key.Binding } var keys = keyMap{ - TabOverview: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "обзор")), - TabPeers: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "пиры")), - TabFiles: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "файлы")), - TabTrackers: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "трекеры")), + TabOverview: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "обзор")), + TabNetwork: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "сеть")), TabJournal: key.NewBinding(key.WithKeys("j", "5"), key.WithHelp("J/5", "журнал")), TabLogs: key.NewBinding(key.WithKeys("l", "6"), key.WithHelp("L/6", "логи")), TabAbout: key.NewBinding(key.WithKeys("?", "7"), key.WithHelp("?/7", "о программе")), @@ -93,9 +91,17 @@ var keys = keyMap{ OpenNew: key.NewBinding(key.WithKeys("o"), key.WithHelp("O", "открыть торрент")), Clear: key.NewBinding(key.WithKeys("c"), key.WithHelp("C", "очистить")), Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("Q", "выйти")), + ToggleFile: key.NewBinding(key.WithKeys("enter"), key.WithHelp("Enter", "вкл/выкл файл")), FilePicker: key.NewBinding(key.WithKeys("f"), key.WithHelp("F", "выбрать файл")), } +// ── Кэш вида ────────────────────────────────────────────────────────────────── +// View() имеет value receiver, поэтому кэш хранится на уровне пакета. +var ( + viewCache string + viewCacheDirty = true +) + // ── Модель ──────────────────────────────────────────────────────────────────── type model struct { @@ -111,36 +117,64 @@ type model struct { journalTable table.Model historyItems []history.Item + // Главное меню + mainMenuMode bool + mainMenuCursor int + + // Настройки + settingsMode bool + settingsInputs []textinput.Model + settingsFocus int + // Экран нового торрента - welcomeMode bool - inputs []textinput.Model - focusedInput int - torrentPath string - outputDir string - validationErr string + newDownloadMode bool + inputs []textinput.Model + focusedInput int + torrentPath string + outputDir string + validationErr string // Загрузка loadingMetadata bool spinner spinner.Model - streamingMode bool + showAdvanced bool // Таблицы peersTable table.Model filesTable table.Model trackersTable table.Model - // Лог сессий - logWriter *sessionlog.LogWriter - logsTable table.Model - logEntries []sessionlog.Entry - logViewMode bool // true = просмотр содержимого лога - logContent string // содержимое открытого лога - logScrollY int // позиция прокрутки в просмотрщике - logsMode bool // true = экран логов (overlay) - aboutMode bool // true = экран о программе (overlay) + // Системный лог + logScrollY int + logAutoScroll bool + + // Overlays + logsMode bool + aboutMode bool + additionalMode bool // Дополнительное меню (overlay) // Анимация логотипа logoEngine *LogoEngine + + // Matrix Rain + boxMatrix *MatrixEngine + + // Отключение анимаций + disableAnimations bool + + // Анимации переходов + transitioning bool + transitionProgress float64 +} + +func (m *model) startTransition() { + if m.disableAnimations { + m.transitioning = false + m.transitionProgress = 1.0 + return + } + m.transitioning = true + m.transitionProgress = 0.0 } func initialModel(controller *appcore.Controller) model { @@ -155,40 +189,86 @@ func initialModel(controller *appcore.Controller) model { outputInput.CharLimit = 512 outputInput.Width = 62 + // Настройки + cfgDir := "." if home, err := os.UserHomeDir(); err == nil { - outputInput.SetValue(home) - } else { - outputInput.SetValue(".") + cfgDir = home } + cfgToken := "" + cfgDl := "0" + cfgUl := "0" + + cfgAnim := "0" + + cfg, err := config.Load() + if err == nil { + if cfg.DefaultDir != "" { + cfgDir = cfg.DefaultDir + } + cfgToken = cfg.TelegramToken + cfgDl = fmt.Sprintf("%d", cfg.DownloadLimit) + cfgUl = fmt.Sprintf("%d", cfg.UploadLimit) + if cfg.DisableAnimations { + cfgAnim = "1" + } + } else if envTok := os.Getenv("TG_BOT_TOKEN"); envTok != "" { + cfgToken = envTok + } + + outputInput.SetValue(cfgDir) + + setDirInp := textinput.New() + setDirInp.Placeholder = "Папка по умолчанию" + setDirInp.SetValue(cfgDir) + setDirInp.Width = 40 + + setDlInp := textinput.New() + setDlInp.Placeholder = "0 = без лимита" + setDlInp.SetValue(cfgDl) + setDlInp.Width = 40 + + setUlInp := textinput.New() + setUlInp.Placeholder = "0 = без лимита" + setUlInp.SetValue(cfgUl) + setUlInp.Width = 40 + + setTgInp := textinput.New() + setTgInp.Placeholder = "Токен BotFather" + setTgInp.SetValue(cfgToken) + setTgInp.Width = 40 + + setAnimInp := textinput.New() + setAnimInp.Placeholder = "1 - отключить, 0 - включить" + setAnimInp.SetValue(cfgAnim) + setAnimInp.Width = 40 s := spinner.New() s.Spinner = spinner.Points s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)) - // Лог по умолчанию (до старта торрента) - var lw *sessionlog.LogWriter - if w, err := sessionlog.NewLogWriter("startup"); err == nil { - lw = w - } else { - log.SetOutput(io.Discard) - } + log.SetOutput(io.Discard) m := model{ - controller: controller, - journalTable: newJournalTable(), - welcomeMode: true, - inputs: []textinput.Model{torrentInput, outputInput}, - focusedInput: 0, - spinner: s, - peersTable: newPeersTable(), - filesTable: newFilesTable(), - trackersTable: newTrackersTable(), - logsTable: newLogsTable(), - logWriter: lw, - logoEngine: NewLogoEngine(28, 12, 4.0, 1.0), + controller: controller, + journalTable: newJournalTable(), + mainMenuMode: true, + mainMenuCursor: 0, + settingsInputs: []textinput.Model{setDirInp, setDlInp, setUlInp, setTgInp, setAnimInp}, + settingsFocus: 0, + newDownloadMode: false, + inputs: []textinput.Model{torrentInput, outputInput}, + focusedInput: 0, + spinner: s, + peersTable: newPeersTable(), + filesTable: newFilesTable(), + trackersTable: newTrackersTable(), + logoEngine: NewLogoEngine(28, 12, 4.0, 1.0), + boxMatrix: NewMatrixEngine(85, 40), + } + if cfg != nil { + m.disableAnimations = cfg.DisableAnimations } m.loadHistory() - m.loadLogs() return m } @@ -218,30 +298,12 @@ func (m *model) loadHistory() { } } -func (m *model) loadLogs() { - entries, err := sessionlog.List() - if err != nil { - return - } - m.logEntries = entries - var rows []table.Row - for _, e := range entries { - size := "-" - if info, err := os.Stat(e.Path); err == nil { - size = formatBytes(info.Size()) - } - rows = append(rows, table.Row{ - e.Name, - e.ModTime.Format("02.01 15:04"), - size, - }) - } - m.logsTable.SetRows(rows) -} - // ── Init ────────────────────────────────────────────────────────────────────── func (m model) Init() tea.Cmd { + if m.disableAnimations { + return tea.Batch(textinput.Blink, m.spinner.Tick) + } return tea.Batch(textinput.Blink, m.spinner.Tick, animTickCmd()) } @@ -254,7 +316,7 @@ func tickCmd() tea.Cmd { } func animTickCmd() tea.Cmd { - return tea.Tick(50*time.Millisecond, func(t time.Time) tea.Msg { + return tea.Tick(80*time.Millisecond, func(t time.Time) tea.Msg { return animTickMsg(t) }) } @@ -273,8 +335,11 @@ return POSIX path of theFile` } } -func startTorrentCmd(controller *appcore.Controller, torrentPath, outputDir string) tea.Cmd { +func startTorrentCmd(controller *appcore.Controller, torrentPath, outputDir, tgToken string) tea.Cmd { return func() tea.Msg { + if tgToken != "" { + _ = controller.InitTelegramBot(tgToken) + } err := controller.StartTorrent(torrentPath, outputDir) return startTorrentResultMsg{err: err} } @@ -283,13 +348,18 @@ func startTorrentCmd(controller *appcore.Controller, torrentPath, outputDir stri func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd + // Любое событие, кроме animTickMsg, тоже делает вид грязным + switch msg.(type) { + case animTickMsg: + // обрабатывается ниже, dirty ставится там + default: + viewCacheDirty = true + } + switch msg := msg.(type) { case tea.KeyMsg: // Выход — работает всегда if key.Matches(msg, keys.Quit) { - if m.logWriter != nil { - m.logWriter.Close() - } m.controller.StopTorrent() return m, tea.Quit } @@ -302,78 +372,60 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } if m.logsMode { - if m.logViewMode { - switch msg.String() { - case "esc", "b", "q": - m.logViewMode = false - case "up", "k": - if m.logScrollY > 0 { - m.logScrollY-- - } - case "down", "j": - m.logScrollY++ - case "pgup", "u": - m.logScrollY -= 20 - if m.logScrollY < 0 { - m.logScrollY = 0 - } - case "pgdown", "d": - m.logScrollY += 20 - } - return m, nil - } switch msg.String() { - case "esc", "q": + case "esc", "l", "L", "q", "6": m.logsMode = false - m.loadLogs() - return m, nil - case "enter": - row := m.logsTable.Cursor() - if row >= 0 && row < len(m.logEntries) { - content, err := sessionlog.Read(m.logEntries[row].Path) - if err == nil { - m.logContent = content - m.logScrollY = 0 - m.logViewMode = true - } + case "up", "k": + m.logAutoScroll = false + m.logScrollY-- + if m.logScrollY < 0 { + m.logScrollY = 0 } - return m, nil - case "x", "X": - row := m.logsTable.Cursor() - if row >= 0 && row < len(m.logEntries) { - sessionlog.Delete(m.logEntries[row].Path) - m.loadLogs() + case "down", "j": + m.logAutoScroll = false + m.logScrollY++ + case "pgup", "u": + m.logAutoScroll = false + m.logScrollY -= 20 + if m.logScrollY < 0 { + m.logScrollY = 0 } - return m, nil - case "o": - if dir, err := sessionlog.Dir(); err == nil { - exec.Command("open", dir).Start() - } - return m, nil + case "pgdown", "d": + m.logAutoScroll = false + m.logScrollY += 20 + case "f", "F": + m.logAutoScroll = true } - m.logsTable, cmd = m.logsTable.Update(msg) - return m, cmd + return m, nil } - // Открыть оверлей о программе - if msg.String() == "?" || msg.String() == "7" { - m.aboutMode = true - return m, nil - } - // Открыть оверлей логов (работает с любого экрана) - if msg.String() == "l" || msg.String() == "L" || msg.String() == "6" { - m.logsMode = true - m.logViewMode = false - m.loadLogs() - return m, nil - } - // Открыть оверлей журнала (работает с любого экрана) - if msg.String() == "j" || msg.String() == "J" || msg.String() == "5" { - if !m.journalMode { + // Дополнительное меню + if m.additionalMode { + switch msg.String() { + case "esc", "q", "?": + m.additionalMode = false + return m, nil + case "j", "J": + m.additionalMode = false m.journalMode = true m.loadHistory() return m, nil + case "l", "L": + m.additionalMode = false + m.logsMode = true + m.logAutoScroll = true + return m, nil + case "a", "A": + m.additionalMode = false + m.aboutMode = true + return m, nil } + return m, nil + } + + if msg.String() == "?" { + m.additionalMode = true + return m, nil } // ── Журнал ── @@ -381,7 +433,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "n", "esc", "q", "j", "J": m.journalMode = false - m.welcomeMode = true + m.mainMenuMode = true return m, nil case "enter": if len(m.historyItems) > 0 { @@ -393,7 +445,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.outputDir = it.OutputDir m.journalMode = false m.loadingMetadata = true - return m, tea.Batch(m.spinner.Tick, startTorrentCmd(m.controller, m.torrentPath, m.outputDir)) + + cfg, _ := config.Load() + if cfg != nil && cfg.TelegramToken != "" { + _ = m.controller.InitTelegramBot(cfg.TelegramToken) + } + + return m, tea.Batch(m.spinner.Tick, startTorrentCmd(m.controller, m.torrentPath, m.outputDir, cfg.TelegramToken)) } } } @@ -406,51 +464,160 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } - // ── Экран приветствия ── - if m.welcomeMode { + // ── Главное Меню ── + if m.mainMenuMode { switch msg.String() { - case "j", "J", "5": - m.welcomeMode = false - m.journalMode = true - m.loadHistory() + case "up", "k": + m.mainMenuCursor-- + if m.mainMenuCursor < 0 { + m.mainMenuCursor = 3 + } + return m, nil + case "down", "j", "tab": + m.mainMenuCursor++ + if m.mainMenuCursor > 3 { + m.mainMenuCursor = 0 + } + return m, nil + case "enter": + switch m.mainMenuCursor { + case 0: // Новая загрузка + m.mainMenuMode = false + m.newDownloadMode = true + m.startTransition() + return m, nil + case 1: // История + m.mainMenuMode = false + m.journalMode = true + m.loadHistory() + m.startTransition() + return m, nil + case 2: // Настройки + m.mainMenuMode = false + m.settingsMode = true + m.settingsFocus = 0 + m.startTransition() + return m, textinput.Blink + case 3: // Выход + return m, tea.Quit + } + case "q", "esc": + return m, tea.Quit + } + return m, nil + } + + // ── Настройки ── + if m.settingsMode { + switch msg.String() { + case "esc": + m.settingsMode = false + m.mainMenuMode = true + m.startTransition() + return m, nil + case "tab", "down": + m.settingsInputs[m.settingsFocus].Blur() + m.settingsFocus = (m.settingsFocus + 1) % len(m.settingsInputs) + m.settingsInputs[m.settingsFocus].Focus() + return m, nil + case "shift+tab", "up": + m.settingsInputs[m.settingsFocus].Blur() + m.settingsFocus = (m.settingsFocus - 1 + len(m.settingsInputs)) % len(m.settingsInputs) + m.settingsInputs[m.settingsFocus].Focus() + return m, nil + case "enter": + // Сохранить + cfg, _ := config.Load() + if cfg == nil { + cfg = &config.Config{} + } + cfg.DefaultDir = strings.TrimSpace(m.settingsInputs[0].Value()) + + var dlLimit, ulLimit int + fmt.Sscanf(strings.TrimSpace(m.settingsInputs[1].Value()), "%d", &dlLimit) + fmt.Sscanf(strings.TrimSpace(m.settingsInputs[2].Value()), "%d", &ulLimit) + + cfg.DownloadLimit = dlLimit + cfg.UploadLimit = ulLimit + cfg.TelegramToken = strings.TrimSpace(m.settingsInputs[3].Value()) + + oldDisable := m.disableAnimations + animVal := strings.TrimSpace(m.settingsInputs[4].Value()) + cfg.DisableAnimations = (animVal == "1" || animVal == "true" || animVal == "да") + + _ = config.Save(cfg) + + m.disableAnimations = cfg.DisableAnimations + m.settingsMode = false + m.mainMenuMode = true + m.startTransition() + + if oldDisable && !m.disableAnimations { + return m, animTickCmd() + } + return m, nil + } + m.settingsInputs[m.settingsFocus], cmd = m.settingsInputs[m.settingsFocus].Update(msg) + return m, cmd + } + + // ── Экран приветствия (Новая загрузка) ── + if m.newDownloadMode { + switch msg.String() { + case "esc": + m.newDownloadMode = false + m.mainMenuMode = true + m.startTransition() return m, nil case "tab", "down": m.inputs[m.focusedInput].Blur() - m.focusedInput = (m.focusedInput + 1) % len(m.inputs) + m.focusedInput = (m.focusedInput + 1) % 2 m.inputs[m.focusedInput].Focus() return m, nil case "shift+tab", "up": m.inputs[m.focusedInput].Blur() - m.focusedInput = (m.focusedInput - 1 + len(m.inputs)) % len(m.inputs) + m.focusedInput = (m.focusedInput - 1 + 2) % 2 m.inputs[m.focusedInput].Focus() return m, nil case "f": // Открыть Finder для выбора .torrent файла - return m, filePickerCmd() + if m.focusedInput == 0 { + return m, filePickerCmd() + } case "enter": torrentPath := strings.TrimSpace(m.inputs[0].Value()) outputDir := strings.TrimSpace(m.inputs[1].Value()) + + cfg, _ := config.Load() + tgToken := "" + if cfg != nil { + tgToken = cfg.TelegramToken + } + if err := validateInputs(torrentPath, outputDir); err != nil { m.validationErr = err.Error() return m, nil } + m.torrentPath = torrentPath m.outputDir = outputDir m.validationErr = "" + m.newDownloadMode = false m.loadingMetadata = true - m.welcomeMode = false - return m, tea.Batch(m.spinner.Tick, startTorrentCmd(m.controller, torrentPath, outputDir)) + return m, tea.Batch(m.spinner.Tick, startTorrentCmd(m.controller, torrentPath, outputDir, tgToken)) } m.inputs[m.focusedInput], cmd = m.inputs[m.focusedInput].Update(msg) return m, cmd } + + // ── Экран загрузки ── if m.loadingMetadata { if msg.String() == "esc" { m.controller.StopTorrent() m.loadingMetadata = false - m.welcomeMode = true + m.mainMenuMode = true return m, nil } m.spinner, cmd = m.spinner.Update(msg) @@ -461,39 +628,28 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, keys.TabOverview): m.activeTab = 0 - case key.Matches(msg, keys.TabPeers): + case key.Matches(msg, keys.TabNetwork): m.activeTab = 1 - case key.Matches(msg, keys.TabFiles): - m.activeTab = 2 - case key.Matches(msg, keys.TabTrackers): - m.activeTab = 3 case key.Matches(msg, keys.NextTab): - m.activeTab = (m.activeTab + 1) % 4 + m.activeTab = (m.activeTab + 1) % 2 case key.Matches(msg, keys.PrevTab): - m.activeTab = (m.activeTab - 1 + 4) % 4 - case msg.String() == "v" || msg.String() == "V": - m.streamingMode = !m.streamingMode - m.controller.SetSequentialMode(m.streamingMode) - if m.streamingMode { - m.controller.StartStreamServer() - } else { - m.controller.StopStreamServer() - } + m.activeTab = (m.activeTab - 1 + 2) % 2 + case key.Matches(msg, keys.PauseResume): phase := m.status.Phase if phase == "stopped" || phase == "failed" || phase == "" { m.loadingMetadata = true - return m, tea.Batch(m.spinner.Tick, startTorrentCmd(m.controller, m.torrentPath, m.outputDir)) + return m, tea.Batch(m.spinner.Tick, startTorrentCmd(m.controller, m.torrentPath, m.outputDir, m.inputs[2].Value())) } m.controller.StopTorrent() case key.Matches(msg, keys.OpenNew): m.controller.StopTorrent() - m.welcomeMode = true + m.mainMenuMode = true m.status = torrent.Status{} return m, nil case key.Matches(msg, keys.Clear): m.controller.StopTorrent() - m.welcomeMode = true + m.mainMenuMode = true m.inputs[0].SetValue("") m.status = torrent.Status{} return m, nil @@ -503,90 +659,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.activeTab { case 1: m.peersTable, cmd = m.peersTable.Update(msg) + m.trackersTable, _ = m.trackersTable.Update(msg) return m, cmd - case 2: + default: + if key.Matches(msg, keys.ToggleFile) { + m.controller.ToggleFilePriority(m.filesTable.Cursor()) + } m.filesTable, cmd = m.filesTable.Update(msg) return m, cmd - case 3: - m.trackersTable, cmd = m.trackersTable.Update(msg) - return m, cmd - case 4: - switch msg.String() { - case "enter": - if len(m.historyItems) > 0 { - selectedRow := m.journalTable.Cursor() - if selectedRow >= 0 && selectedRow < len(m.historyItems) { - idx := len(m.historyItems) - 1 - selectedRow - it := m.historyItems[idx] - m.controller.StopTorrent() - m.torrentPath = it.TorrentPath - m.outputDir = it.OutputDir - m.loadingMetadata = true - m.activeTab = 0 - return m, tea.Batch(m.spinner.Tick, startTorrentCmd(m.controller, m.torrentPath, m.outputDir)) - } - } - } - if cmd, handled := m.handleJournalKeys(msg); handled { - return m, cmd - } - m.journalTable, cmd = m.journalTable.Update(msg) - return m, cmd - case 5: // Логи - if m.logViewMode { - // Просмотр лога — навигация - switch msg.String() { - case "esc", "b", "q": - m.logViewMode = false - return m, nil - case "up", "k": - if m.logScrollY > 0 { - m.logScrollY-- - } - return m, nil - case "down", "j": - m.logScrollY++ - return m, nil - case "pgup", "u": - m.logScrollY -= 20 - if m.logScrollY < 0 { - m.logScrollY = 0 - } - return m, nil - case "pgdown", "d": - m.logScrollY += 20 - return m, nil - } - } else { - // Список логов - switch msg.String() { - case "enter": - row := m.logsTable.Cursor() - if row >= 0 && row < len(m.logEntries) { - content, err := sessionlog.Read(m.logEntries[row].Path) - if err == nil { - m.logContent = content - m.logScrollY = 0 - m.logViewMode = true - } - } - return m, nil - case "x", "X": - row := m.logsTable.Cursor() - if row >= 0 && row < len(m.logEntries) { - sessionlog.Delete(m.logEntries[row].Path) - m.loadLogs() - } - return m, nil - case "o": - if dir, err := sessionlog.Dir(); err == nil { - exec.Command("open", dir).Start() - } - return m, nil - } - m.logsTable, cmd = m.logsTable.Update(msg) - return m, cmd - } } case filePickerMsg: @@ -599,22 +679,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case startTorrentResultMsg: if msg.err != nil { m.loadingMetadata = false - m.welcomeMode = true + m.mainMenuMode = true m.validationErr = msg.err.Error() return m, nil } m.status = m.controller.Status() - // Открываем новый лог для этой сессии - if m.logWriter != nil { - m.logWriter.Close() - } - torrentName := m.status.Name - if torrentName == "" { - torrentName = filepath.Base(m.torrentPath) - } - if lw, err := sessionlog.NewLogWriter(torrentName); err == nil { - m.logWriter = lw - } // For magnet links, LoadTorrent returns nil immediately but metadata // is still downloading in the background. Keep loading state and poll. phase := m.status.Phase @@ -627,7 +696,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tickCmd() case tickMsg: - if m.welcomeMode { + if m.mainMenuMode || m.newDownloadMode || m.settingsMode { return m, nil } // While loading metadata (esp. for magnet links), poll engine phase @@ -636,7 +705,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { phase := m.status.Phase if phase == "failed" { m.loadingMetadata = false - m.welcomeMode = true + m.mainMenuMode = true m.validationErr = m.status.LastError if m.validationErr == "" { m.validationErr = "загрузка метаданных не удалась" @@ -656,16 +725,28 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateTables() // Refresh data for active tab if m.activeTab == 4 || m.journalMode { - m.loadHistory() - } else if m.activeTab == 5 { - m.loadLogs() + m.loadHistory() } return m, tickCmd() case animTickMsg: + if m.disableAnimations { + return m, nil + } + if m.transitioning { + m.transitionProgress += 0.2 // 80ms per tick -> 5 ticks for 400ms + if m.transitionProgress >= 1.0 { + m.transitioning = false + m.transitionProgress = 1.0 + } + } if m.logoEngine != nil { m.logoEngine.Update(time.Now()) } + if m.boxMatrix != nil { + m.boxMatrix.Update() + } + viewCacheDirty = true return m, animTickCmd() case spinner.TickMsg: @@ -738,7 +819,6 @@ func (m *model) recalculateTableSizes() { m.filesTable.SetHeight(tableHeight) m.trackersTable.SetHeight(tableHeight) m.journalTable.SetHeight(tableHeight) - m.logsTable.SetHeight(tableHeight) } func (m *model) updateTables() { @@ -764,8 +844,12 @@ func (m *model) updateTables() { var fileRows []table.Row for _, f := range m.status.Files { + prefix := "[ ] " + if f.Priority > 0 { + prefix = "[x] " + } fileRows = append(fileRows, table.Row{ - f.Path, + prefix + f.Path, formatBytes(int64(f.Length)), }) } @@ -786,23 +870,57 @@ func (m *model) updateTables() { // ── Отрисовка ───────────────────────────────────────────────────────────────── func (m model) View() string { + // Если dirty флаг не установлен — вернуть закэшированный результат + if !viewCacheDirty && viewCache != "" { + return viewCache + } + // Режимы-overlays работают поверх любого экрана + if m.additionalMode { + return m.viewAdditionalMenu() + } if m.aboutMode { return m.viewOverlayAbout() } if m.logsMode { return m.viewOverlayLogs() } + + if m.width == 0 || m.height == 0 { + return "Инициализация..." + } + + var content string + if m.journalMode { - return m.viewJournal() + content = m.viewJournal() + } else if m.settingsMode { + content = m.viewSettings() + } else if m.newDownloadMode { + content = m.viewNewDownload() + } else if m.mainMenuMode { + content = m.viewMainMenu() + } else if m.loadingMetadata { + content = m.viewLoading() + } else { + content = m.viewDashboard() } - if m.welcomeMode { - return m.viewWelcome() + + if m.transitioning { + // Анимация плавного выезда снизу вверх + offset := int((1.0 - m.transitionProgress) * 4.0) + content = lipgloss.NewStyle().MarginTop(offset).Render(content) } - if m.loadingMetadata { - return m.viewLoading() - } - return m.viewDashboard() + + box := lipgloss.NewStyle(). + Width(m.width - 6). + Height(m.height - 4). + Render(content) + + result := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box) + viewCache = result + viewCacheDirty = false + return result } // ── Overlay: О программе ────────────────────────────────────────────────────── @@ -854,14 +972,13 @@ func (m model) viewOverlayAbout() string { "", cyan.Render(" ГОРЯЧИЕ КЛАВИШИ"), "", - row("1-7 / Tab", "переключение вкладок"), - row("J / 5", "журнал загрузок"), - row("L / 6", "журнал логов"), - row("? / 7", "эта страница"), - row("Пробел / S", "пауза / продолжить"), + row("Tab / 1-2", "переключение вкладок"), + row("Enter", "вкл/выкл скачивание файла"), + row("Пробел / S", "пауза / продолжить торрент"), row("O", "открыть новый торрент"), - row("F", "выбрать .torrent файл"), - row("C", "очистить и вернуться"), + row("J", "журнал загрузок (история)"), + row("L", "журнал логов (system logs)"), + row("?", "эта страница (дополнительно)"), row("Q / Ctrl+C", "выйти"), "", cyan.Render(" ХРАНИЛИЩЕ"), @@ -893,70 +1010,91 @@ func (m model) viewOverlayLogs() string { cyan := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan)) dim := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)) - var content string - if m.logViewMode { - lines := strings.Split(m.logContent, "\n") - viewH := h - 12 - if viewH < 1 { - viewH = 1 - } - start := m.logScrollY - if start < 0 { - start = 0 - } - if start >= len(lines) { - start = len(lines) - 1 - if start < 0 { - start = 0 - } - } - end := start + viewH - if end > len(lines) { - end = len(lines) - } - var colored []string - for _, l := range lines[start:end] { - switch { - case strings.HasPrefix(l, "#"): - colored = append(colored, lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)).Bold(true).Render(l)) - case strings.Contains(l, "error") || strings.Contains(l, "failed"): - colored = append(colored, lipgloss.NewStyle().Foreground(lipgloss.Color(ColorRed)).Render(l)) - case strings.Contains(l, "DHT") || strings.Contains(l, "peer"): - colored = append(colored, lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)).Render(l)) - default: - colored = append(colored, lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText)).Render(l)) - } - } - scrollInfo := fmt.Sprintf("Строка %d/%d", start+1, len(lines)) - content = lipgloss.JoinVertical(lipgloss.Left, - cyan.Render(" ► Просмотр лога"), - "", - strings.Join(colored, "\n"), - "", - dim.Render(" ↑↓/jk — прокрутка PgUp/PgDn — быстро Esc/B — назад "+scrollInfo), - ) - } else { - var tableContent string - if len(m.logEntries) == 0 { - tableContent = dim.Render(" Логи появятся автоматически после первого запуска торрента.\n Директория: ~/.ztorrent/logs/") - } else { - m.logsTable.SetHeight(h - 14) - tableContent = m.logsTable.View() - } - content = lipgloss.JoinVertical(lipgloss.Left, - cyan.Render(" Журнал логов сессий"), - "", - tableContent, - "", - dim.Render(" Enter — открыть X — удалить O — в Finder Esc/Q — закрыть"), - ) + entries := logger.GlobalBuffer.Snapshot() + viewH := h - 12 + if viewH < 1 { + viewH = 1 } + if m.logAutoScroll { + m.logScrollY = len(entries) - viewH + } + if m.logScrollY < 0 { + m.logScrollY = 0 + } + if m.logScrollY > len(entries)-viewH && len(entries) > viewH { + m.logScrollY = len(entries) - viewH + } + + start := m.logScrollY + end := start + viewH + if end > len(entries) { + end = len(entries) + } + + var colored []string + for _, entry := range entries[start:end] { + timeStr := dim.Render(entry.Time.Format("15:04:05")) + + var catStyle lipgloss.Style + switch entry.Category { + case "ENGINE": + catStyle = StyleLogEngine + case "DHT": + catStyle = StyleLogDHT + case "PEER": + catStyle = StyleLogPeer + case "SYS": + catStyle = StyleLogSys + default: + catStyle = StyleLogDefault + } + + var levelStyle lipgloss.Style + switch entry.Level { + case logger.LevelError: + levelStyle = StyleLogError + case logger.LevelWarn: + levelStyle = StyleLogWarn + case logger.LevelInfo: + levelStyle = StyleLogInfo + case logger.LevelDebug: + levelStyle = StyleLogDebug + } + + msgStyle := StyleLogMsg + if entry.Level == logger.LevelError { + msgStyle = StyleLogMsgError + } + + line := fmt.Sprintf("%s [%-5s] %s %s", + timeStr, + levelStyle.Render(entry.Level.String()), + catStyle.Render(fmt.Sprintf("%-6s", entry.Category)), + msgStyle.Render(entry.Message), + ) + colored = append(colored, line) + } + + scrollInfo := fmt.Sprintf("Строка %d/%d", start+1, len(entries)) + if m.logAutoScroll { + scrollInfo += " (AutoScroll)" + } + + content := lipgloss.JoinVertical(lipgloss.Left, + cyan.Render(" ► Системный лог"), + "", + strings.Join(colored, "\n"), + "", + dim.Render(" ↑↓/jk/PgUp/PgDn — прокрутка F — автоскролл Esc/L — закрыть "+scrollInfo), + ) + box := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color(ColorCyan)). Padding(1, 2). Width(w - 6). + Height(h - 4). Render(content) return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box) @@ -998,72 +1136,225 @@ func (m model) viewJournal() string { // ── Экран приветствия ────────────────────────────────────────────────────────── -func (m model) viewWelcome() string { +func (m model) renderLogo() string { + var zArt string + if m.disableAnimations { + zArt = cachedStaticZArt + } else if m.logoEngine != nil { + zArt = m.logoEngine.Render(time.Now()) + } + return lipgloss.JoinHorizontal(lipgloss.Center, zArt, cachedTorrentArt) +} + +func (m model) viewMainMenu() string { + w := m.width + if w < 80 { w = 80 } + + header := m.renderLogo() + + type menuItem struct { + title string + desc string + } + menuItems := []menuItem{ + {"Новая загрузка", "Добавить торрент-файл или magnet-ссылку"}, + {"История (Журнал)", "Посмотреть ранее загруженные файлы"}, + {"Настройки", "Изменить папку по умолчанию и параметры"}, + {"Выход", "Завершить работу Ztorrent"}, + } + + var renderedItems []string + for i, item := range menuItems { + cursor := " " + titleStyle := StyleMenuText + descStyle := StyleMenuDesc + + if i == m.mainMenuCursor { + cursor = StyleMenuCursor.Render("▶ ") + titleStyle = StyleMenuActive + } + + title := cursor + titleStyle.Render(item.title) + desc := " " + descStyle.Render(item.desc) + renderedItems = append(renderedItems, title, desc) + if i < len(menuItems)-1 { + renderedItems = append(renderedItems, "") // Пустая строка между пунктами + } + } + + itemsBlock := lipgloss.JoinVertical(lipgloss.Left, renderedItems...) + + contentW := 48 + boxWidth := 85 + leftW := (boxWidth - contentW) / 2 + rightW := boxWidth - contentW - leftW + h := lipgloss.Height(itemsBlock) + + var boxContent string + if m.boxMatrix != nil && !m.disableAnimations { + topRain := m.boxMatrix.RenderRegion(0, 0, boxWidth, 2) + leftRain := m.boxMatrix.RenderRegion(0, 2, leftW, h) + rightRain := m.boxMatrix.RenderRegion(boxWidth-rightW, 2, rightW, h) + bottomRain := m.boxMatrix.RenderRegion(0, 2+h, boxWidth, 2) + + paddedItems := lipgloss.NewStyle().Width(contentW).Render(itemsBlock) + + // Быстрая склейка по строкам (обходим тяжелый regex-парсинг в lipgloss.JoinHorizontal) + linesLeft := strings.Split(leftRain, "\n") + linesMid := strings.Split(paddedItems, "\n") + linesRight := strings.Split(rightRain, "\n") + + var midBuilder strings.Builder + midBuilder.Grow(len(leftRain) + len(paddedItems) + len(rightRain)) + for i := 0; i < len(linesMid); i++ { + if i < len(linesLeft) { + midBuilder.WriteString(linesLeft[i]) + } + midBuilder.WriteString(linesMid[i]) + if i < len(linesRight) { + midBuilder.WriteString(linesRight[i]) + } + if i < len(linesMid)-1 { + midBuilder.WriteString("\n") + } + } + middle := midBuilder.String() + + // Быстрая вертикальная склейка (без lipgloss.JoinVertical) + boxContent = topRain + "\n" + middle + "\n" + bottomRain + } else { + centeredItems := lipgloss.PlaceHorizontal(boxWidth, lipgloss.Center, itemsBlock) + boxContent = lipgloss.NewStyle().Padding(2, 0).Render(centeredItems) + } + + + linesContent := strings.Split(boxContent, "\n") + + var menuBuilder strings.Builder + menuBuilder.Grow(len(boxContent) + len(cachedMenuTopBorder) + len(cachedMenuBottomBorder) + 100) + + menuBuilder.WriteString("\n\n") // MarginTop(2) + menuBuilder.WriteString(cachedMenuTopBorder) + menuBuilder.WriteString("\n") + + for i, l := range linesContent { + menuBuilder.WriteString(cachedMenuLeftEdge) + menuBuilder.WriteString(l) + menuBuilder.WriteString(cachedMenuRightEdge) + if i < len(linesContent)-1 { + menuBuilder.WriteString("\n") + } + } + menuBuilder.WriteString("\n") + menuBuilder.WriteString(cachedMenuBottomBorder) + + menuBox := menuBuilder.String() + + // Убрали создание нового стиля — используем кэш + // help уже вычислен как cachedMenuHelp + + body := lipgloss.JoinVertical(lipgloss.Center, + header, + menuBox, + cachedMenuHelp, + ) + + return StyleMenuPadding.Render(body) +} + +func (m model) viewSettings() string { + w := m.width + if w < 80 { w = 80 } + + header := lipgloss.NewStyle().MarginBottom(2).Render( + lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)).Bold(true).Render(" ⚙ НАСТРОЙКИ"), + ) + + labels := []string{ + " Папка загрузок (по ум.) ", + " Лимит скорости (Скач.) ", + " Лимит скорости (Разд.) ", + " Telegram Bot Token ", + " Отключить анимации (1/0)", + } + + var formRows []string + for i := range m.settingsInputs { + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)).Width(26) + if i == m.settingsFocus { + labelStyle = labelStyle.Foreground(lipgloss.Color(ColorCyan)).Bold(true) + } + + indicator := " " + if i == m.settingsFocus { + indicator = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Render("▶ ") + } + + label := indicator + labelStyle.Render(labels[i]) + inputView := m.settingsInputs[i].View() + inputBoxStyle := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(0, 1).Width(44) + if i == m.settingsFocus { + inputBoxStyle = inputBoxStyle.BorderForeground(lipgloss.Color(ColorCyan)) + } else { + inputBoxStyle = inputBoxStyle.BorderForeground(lipgloss.Color(ColorBorder)) + } + + formRows = append(formRows, lipgloss.JoinHorizontal(lipgloss.Center, label, inputBoxStyle.Render(inputView))) + } + + formInner := lipgloss.JoinVertical(lipgloss.Left, + formRows[0], "", formRows[1], "", formRows[2], "", formRows[3], "", formRows[4], + ) + + boxWidth := 85 + centeredFormInner := lipgloss.PlaceHorizontal(boxWidth, lipgloss.Center, formInner) + boxContent := lipgloss.NewStyle().Padding(2, 0).Render(centeredFormInner) + + formBox := lipgloss.NewStyle(). + Border(lipgloss.ThickBorder()). + BorderForeground(lipgloss.Color(ColorBorder)). + Width(boxWidth). + Render(boxContent) + + help := lipgloss.NewStyle(). + Foreground(lipgloss.Color(ColorSubdued)). + MarginTop(1). + Render(fmt.Sprintf( + "%s Tab — сменить поле %s Enter — сохранить %s Esc — отмена", + lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Render("●"), + lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)).Render("●"), + lipgloss.NewStyle().Foreground(lipgloss.Color(ColorRed)).Render("●"), + )) + + errBlock := "" + if m.validationErr != "" { + errBlock = "\n" + lipgloss.NewStyle(). + Foreground(lipgloss.Color(ColorRed)). + Bold(true). + Render("✗ "+m.validationErr) + } + + body := lipgloss.JoinVertical(lipgloss.Left, + header, + formBox, + errBlock, + help, + ) + + return lipgloss.NewStyle().Padding(2, 4).Render(body) +} + +func (m model) viewNewDownload() string { w := m.width if w < 80 { w = 80 } - // ── Логотип: динамический (частицы) + статичный «torrent» ── - var zArt string - if m.logoEngine != nil { - zArt = m.logoEngine.Render(time.Now()) - } - zLines := strings.Split(zArt, "\n") - - // Статичный текст "torrent" в стиле большого ASCII - torrentLines := []string{ - " ████████╗ ██████╗ ██████╗ ██████╗ ███████╗███╗ ██╗████████╗", - " ██╔══╝██╔═══██╗██╔══██╗██╔══██╗██╔════╝████╗ ██║╚══██╔══╝", - " ██║ ██║ ██║██████╔╝██████╔╝█████╗ ██╔██╗██║ ██║ ", - " ██║ ██║ ██║██╔══██╗██╔══██╗██╔══╝ ██║╚████║ ██║ ", - " ██║ ╚██████╔╝██║ ██║██║ ██║███████╗██║ ╚███║ ██║ ", - " ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚══╝ ╚═╝ ", - "", - "", - "", - } - - // Выравниваем Z и torrent по вертикали (по центру) - zH := len(zLines) - tH := len(torrentLines) - maxH := zH - if tH > maxH { - maxH = tH - } - - zPad := (maxH - zH) / 2 - tPad := (maxH - tH) / 2 - - tStyled := lipgloss.NewStyle(). - Foreground(lipgloss.Color(ColorText)). - Bold(true) - - var headerLines []string - for row := 0; row < maxH; row++ { - zRow := row - zPad - var zPart string - const zWidth = 28 - if zRow >= 0 && zRow < zH { - zPart = zLines[zRow] - } - if zPart == "" { - zPart = strings.Repeat(" ", zWidth) - } - - tRow := row - tPad - var tPart string - if tRow >= 0 && tRow < tH { - tPart = torrentLines[tRow] - } - headerLines = append(headerLines, zPart+tStyled.Render(tPart)) - } - - header := strings.Join(headerLines, "\n") + header := m.renderLogo() // Форма - labels := []string{" Торрент-файл ", " Папка загрузки"} - icons := []string{" ", " "} + labels := []string{" Торрент-файл ", " Папка загрузки", " TG Токен "} + icons := []string{" ", " ", " "} var formRows []string for i := range m.inputs { labelStyle := lipgloss.NewStyle(). @@ -1112,12 +1403,16 @@ func (m model) viewWelcome() string { formRows[1], ) + boxWidth := 85 + centeredFormInner := lipgloss.PlaceHorizontal(boxWidth, lipgloss.Center, formInner) + boxContent := lipgloss.NewStyle().Padding(2, 0).Render(centeredFormInner) + formBox := lipgloss.NewStyle(). Border(lipgloss.ThickBorder()). BorderForeground(lipgloss.Color(ColorBorder)). - Padding(1, 3). MarginTop(1). - Render(formInner) + Width(boxWidth). + Render(boxContent) // Кнопка btnStyle := lipgloss.NewStyle(). @@ -1145,14 +1440,12 @@ func (m model) viewWelcome() string { Foreground(lipgloss.Color(ColorSubdued)). MarginTop(1). Render(fmt.Sprintf( - "%s Tab — сменить поле %s F — выбрать файл %s J — журнал %s L — логи %s ? — о программе %s Enter — начать %s Q — выйти", + "%s Tab — сменить поле %s F — выбрать файл %s Enter — начать %s Q — выйти %s ? — дополнительно", lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Render("●"), lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)).Render("●"), - lipgloss.NewStyle().Foreground(lipgloss.Color(ColorYellow)).Render("●"), - lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)).Render("●"), - lipgloss.NewStyle().Foreground(lipgloss.Color("#bd93f9")).Render("●"), lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)).Render("●"), lipgloss.NewStyle().Foreground(lipgloss.Color(ColorRed)).Render("●"), + lipgloss.NewStyle().Foreground(lipgloss.Color("#bd93f9")).Render("●"), )) body := lipgloss.JoinVertical(lipgloss.Left, @@ -1207,6 +1500,11 @@ func (m model) viewLoading() string { // ── Дашборд ─────────────────────────────────────────────────────────────────── func (m model) viewDashboard() string { + if m.torrentPath == "" && m.status.Phase == "" { + // Bot waiting mode + return m.viewBotWaiting() + } + var parts []string parts = append(parts, m.renderHeader()) @@ -1220,6 +1518,44 @@ func (m model) viewDashboard() string { ) } +func (m model) viewBotWaiting() string { + w := m.width - 4 + if w < 40 { + w = 40 + } + + header := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(ColorCyan)). + MarginBottom(1). + Render("🤖 TELEGRAM БОТ АКТИВЕН") + + desc := lipgloss.NewStyle(). + Foreground(lipgloss.Color(ColorText)). + MarginBottom(2). + Render("Ztorrent работает в фоновом режиме сервера.\nЖдем magnet-ссылки или .torrent файлы в Telegram!") + + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(ColorCyan)). + Padding(2, 4). + Width(w). + Align(lipgloss.Center). + Render(lipgloss.JoinVertical(lipgloss.Center, header, desc, m.spinner.View())) + + help := lipgloss.NewStyle(). + Foreground(lipgloss.Color(ColorSubdued)). + MarginTop(1). + Render(fmt.Sprintf("%s L — логи %s Q — выйти", + lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)).Render("●"), + lipgloss.NewStyle().Foreground(lipgloss.Color(ColorRed)).Render("●"), + )) + + return lipgloss.NewStyle(). + Padding(4, 2). + Render(lipgloss.JoinVertical(lipgloss.Center, box, help)) +} + func (m model) renderHeader() string { name := m.status.Name if name == "" { @@ -1234,35 +1570,29 @@ func (m model) renderHeader() string { switch m.status.Phase { case "stopped": icon = "⏸" - phaseColored = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)).Bold(true).Render(phase) + phaseColored = StylePhaseStopped.Render(phase) case "failed": icon = "✗" - phaseColored = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorRed)).Bold(true).Render(phase) + phaseColored = StylePhaseFailed.Render(phase) case "seeding", "done": icon = "✓" - phaseColored = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)).Bold(true).Render(phase) + phaseColored = StylePhaseSeeding.Render(phase) case "downloading": icon = "⬇" - phaseColored = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)).Bold(true).Render(phase) + phaseColored = StylePhaseDownload.Render(phase) default: icon = "◌" - phaseColored = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorYellow)).Bold(true).Render(phase) + phaseColored = StylePhaseDefault.Render(phase) } left := lipgloss.JoinHorizontal(lipgloss.Center, - lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Bold(true).Render("⚡ ZTORRENT"), - lipgloss.NewStyle().Foreground(lipgloss.Color(ColorBorder)).Render(" │ "), - lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText)).Render(name), + StyleHeaderBrand.Render("⚡ ZTORRENT"), + StyleHeaderSep.Render(" │ "), + StyleHeaderName.Render(name), ) - var streamBadge string - if m.streamingMode { - streamBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#0b1017")).Background(lipgloss.Color(ColorPink)).Bold(true).Padding(0, 1).Render("▶ STREAM") + " " - } - right := lipgloss.JoinHorizontal(lipgloss.Center, - streamBadge, - lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)).Render(icon+" "), + StyleHeaderIcon.Render(icon+" "), phaseColored, ) @@ -1277,11 +1607,7 @@ func (m model) renderHeader() string { pad := strings.Repeat(" ", padLen) headerLine := left + pad + right - headerStyled := lipgloss.NewStyle(). - Border(lipgloss.NormalBorder(), false, false, true, false). - BorderForeground(lipgloss.Color(ColorBorder)). - PaddingBottom(0). - Render(headerLine) + headerStyled := StyleHeaderLine.Render(headerLine) return headerStyled } @@ -1290,28 +1616,27 @@ func (m model) renderKPICards() string { progress := clampProgress(m.status.Progress) barWidth := 20 filled := int(math.Round(float64(barWidth) * progress)) - bar := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)).Render(strings.Repeat("█", filled)) + - lipgloss.NewStyle().Foreground(lipgloss.Color(ColorBorder)).Render(strings.Repeat("░", barWidth-filled)) + bar := StyleKPIBarFill.Render(strings.Repeat("█", filled)) + + StyleKPIBarEmpty.Render(strings.Repeat("░", barWidth-filled)) progressCard := makeCard("ПРОГРЕСС", - fmt.Sprintf("%s %s", bar, lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan)).Render(formatPercent(progress))), + fmt.Sprintf("%s %s", bar, StyleKPIProgress.Render(formatPercent(progress))), ColorCyan, 36) downCard := makeCard("↓ СКАЧИВАНИЕ", - lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorGreen)).Render(formatBytesRate(m.status.DownloadSpeed)), + StyleKPIDown.Render(formatBytesRate(m.status.DownloadSpeed)), ColorGreen, 16) upCard := makeCard("↑ ОТДАЧА", - lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorYellow)).Render(formatBytesRate(m.status.UploadSpeed)), + StyleKPIUp.Render(formatBytesRate(m.status.UploadSpeed)), ColorYellow, 16) etaCard := makeCard("⏱ ВРЕМЯ", - lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorPink)).Render(formatETA(m.status)), + StyleKPIEta.Render(formatETA(m.status)), ColorPink, 14) peersCard := makeCard("◉ ПИРЫ", - lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorText)). - Render(fmt.Sprintf("%d / %d", m.status.ActivePeers, m.status.PeerCount)), + StyleKPIPeers.Render(fmt.Sprintf("%d / %d", m.status.ActivePeers, m.status.PeerCount)), ColorBorder, 12) row := lipgloss.JoinHorizontal(lipgloss.Top, @@ -1322,10 +1647,7 @@ func (m model) renderKPICards() string { func makeCard(title, value, borderColor string, w int) string { inner := lipgloss.JoinVertical(lipgloss.Left, - lipgloss.NewStyle(). - Foreground(lipgloss.Color(ColorSubdued)). - Bold(true). - Render(title), + StyleCardTitle.Render(title), value, ) return lipgloss.NewStyle(). @@ -1342,9 +1664,7 @@ func (m model) renderTabs() string { count int }{ {" Обзор", -1}, - {" Пиры", len(m.status.Peers)}, - {" Файлы", len(m.status.Files)}, - {" Трекеры", len(m.status.Trackers)}, + {" Сеть", len(m.status.Peers) + len(m.status.Trackers)}, } var tabs []string @@ -1385,16 +1705,69 @@ func (m model) renderTabContent() string { switch m.activeTab { case 1: - return m.peersTable.View() - case 2: - return m.filesTable.View() - case 3: - return m.trackersTable.View() + peersView := m.peersTable.View() + trackersView := m.trackersTable.View() + return lipgloss.JoinVertical(lipgloss.Left, + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan)).Render(" ПИРЫ"), + peersView, + "", + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan)).Render(" ТРЕКЕРЫ"), + trackersView, + ) default: - return m.renderOverview(contentH) + overview := m.renderOverview(contentH) + filesView := m.filesTable.View() + return lipgloss.JoinVertical(lipgloss.Left, + overview, + "", + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan)).Render(" ФАЙЛЫ"), + filesView, + ) } } +func (m model) viewAdditionalMenu() string { + w := 50 + title := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(ColorCyan)). + Border(lipgloss.NormalBorder(), false, false, true, false). + BorderForeground(lipgloss.Color(ColorBorder)). + Width(w). + Render(" ДОПОЛНИТЕЛЬНО") + + inner := lipgloss.JoinVertical(lipgloss.Left, + title, + "", + m.renderAdditionalMenu(), + ) + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(ColorBorder)). + Padding(0, 1). + Width(w + 2). + Render(inner) +} + +func (m model) renderAdditionalMenu() string { + cyan := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan)) + pink := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Bold(true) + val := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText)) + shortcut := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorYellow)) + + lines := []string{ + pink.Render(" ⚡ ДОПОЛНИТЕЛЬНО"), + "", + " " + shortcut.Render("[J]") + val.Render(" Журнал скачиваний (История)"), + " " + shortcut.Render("[L]") + val.Render(" Системные логи"), + " " + shortcut.Render("[A]") + val.Render(" О программе"), + "", + cyan.Render(" [Esc] Назад"), + } + + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + func renderAboutTab() string { cyan := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan)) dim := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)) @@ -1427,9 +1800,9 @@ func renderAboutTab() string { "", cyan.Render(" ГОРЯЧИЕ КЛАВИШИ"), "", - row("1-7 / Tab", "переключение вкладок"), + row("1-2 / Tab", "переключение вкладок"), row("J", "журнал загрузок"), - row("L", "журнал логов"), + row("L", "системный лог"), row("?", "эта страница"), row("Пробел / S", "пауза / продолжить"), row("O", "открыть новый торрент"), @@ -1445,86 +1818,7 @@ func renderAboutTab() string { return lipgloss.JoinVertical(lipgloss.Left, lines...) } -func (m model) renderLogsTab(h int) string { - title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan)) - dim := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)) - if m.logViewMode { - // Просмотр содержимого лога - lines := strings.Split(m.logContent, "\n") - viewH := h - 4 - if viewH < 1 { - viewH = 1 - } - start := m.logScrollY - if start < 0 { - start = 0 - } - if start >= len(lines) { - start = len(lines) - 1 - if start < 0 { - start = 0 - } - } - end := start + viewH - if end > len(lines) { - end = len(lines) - } - visibleLines := lines[start:end] - - // Цветовая подсветка лога - var coloredLines []string - for _, l := range visibleLines { - var styled string - switch { - case strings.HasPrefix(l, "#"): - styled = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)).Bold(true).Render(l) - case strings.HasPrefix(l, "**") || strings.HasPrefix(l, "*"): - styled = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorYellow)).Render(l) - case strings.Contains(l, "error") || strings.Contains(l, "failed") || strings.Contains(l, "Ошибка"): - styled = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorRed)).Render(l) - case strings.Contains(l, "DHT") || strings.Contains(l, "peer") || strings.Contains(l, "трекер"): - styled = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)).Render(l) - case strings.HasPrefix(l, "---") || strings.HasPrefix(l, "```"): - styled = dim.Render(l) - default: - styled = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText)).Render(l) - } - coloredLines = append(coloredLines, styled) - } - - content := strings.Join(coloredLines, "\n") - scrollInfo := fmt.Sprintf("Строка %d / %d", start+1, len(lines)) - help := dim.Render("↕ ↑↓/jk — прокрутка PgUp/PgDn — быстро Esc/B — назад   "+scrollInfo) - - return lipgloss.JoinVertical(lipgloss.Left, - title.Render("► Просмотр лога"), - "", - content, - "", - help, - ) - } - - // Список логов - if len(m.logEntries) == 0 { - return lipgloss.JoinVertical(lipgloss.Left, - title.Render("Журнал логов сессий"), - "", - dim.Render(" Логи появятся автоматически после первого запуска торрента"), - dim.Render(" Директория: ~/.ztorrent/logs/"), - ) - } - - help := dim.Render("Enter — открыть X — удалить O — открыть папку") - return lipgloss.JoinVertical(lipgloss.Left, - title.Render("Журнал логов сессий"), - "", - m.logsTable.View(), - "", - help, - ) -} func (m model) renderOverview(h int) string { leftW := 44 @@ -1534,10 +1828,6 @@ func (m model) renderOverview(h int) string { } // ── Левая панель: детали ── - sectionTitle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan)) - dim := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)) - val := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText)) - totalSize := "—" if m.status.TotalBytes > 0 { totalSize = formatBytes(m.status.TotalBytes) @@ -1546,27 +1836,25 @@ func (m model) renderOverview(h int) string { } rows := []string{ - sectionTitle.Render("ИНФОРМАЦИЯ О ТОРРЕНТЕ"), + StyleSectionTitle.Render("ИНФОРМАЦИЯ О ТОРРЕНТЕ"), "", - dim.Render("Кусочки: ") + val.Render(fmt.Sprintf("%d / %d", m.status.CompletedPieces, m.status.PieceCount)), - dim.Render("Размер кусочка: ") + val.Render(formatBytes(int64(m.status.PieceLength))), - dim.Render("Скачано: ") + val.Render(formatBytes(m.status.DownloadedBytes)+" / "+totalSize), - dim.Render("Отдано: ") + val.Render(formatBytes(m.status.UploadedBytes)), - dim.Render("Путь сохранения:"), - val.Render(" "+m.status.OutputPath), + StyleDim.Render("Кусочки: ") + StyleVal.Render(fmt.Sprintf("%d / %d", m.status.CompletedPieces, m.status.PieceCount)), + StyleDim.Render("Размер кусочка: ") + StyleVal.Render(formatBytes(int64(m.status.PieceLength))), + StyleDim.Render("Скачано: ") + StyleVal.Render(formatBytes(m.status.DownloadedBytes)+" / "+totalSize), + StyleDim.Render("Отдано: ") + StyleVal.Render(formatBytes(m.status.UploadedBytes)), + StyleDim.Render("Путь сохранения:"), + StyleVal.Render(" "+m.status.OutputPath), "", - sectionTitle.Render("СКОРОСТЬ"), + StyleSectionTitle.Render("СКОРОСТЬ"), "", - dim.Render("Скачивание: ") + lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)).Bold(true).Render(formatBytesRate(m.status.DownloadSpeed)), - dim.Render("Отдача: ") + lipgloss.NewStyle().Foreground(lipgloss.Color(ColorYellow)).Bold(true).Render(formatBytesRate(m.status.UploadSpeed)), - dim.Render("ETA: ") + lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Bold(true).Render(formatETA(m.status)), + StyleDim.Render("Скачивание: ") + StyleSpeedDown.Render(formatBytesRate(m.status.DownloadSpeed)), + StyleDim.Render("Отдача: ") + StyleSpeedUp.Render(formatBytesRate(m.status.UploadSpeed)), + StyleDim.Render("ETA: ") + StyleETA.Render(formatETA(m.status)), } if m.status.LastError != "" { rows = append(rows, "") - rows = append(rows, lipgloss.NewStyle(). - Foreground(lipgloss.Color(ColorRed)).Bold(true). - Render("⚠ "+m.status.LastError)) + rows = append(rows, StyleErrText.Render("⚠ "+m.status.LastError)) } leftPanel := lipgloss.NewStyle(). @@ -1578,16 +1866,16 @@ func (m model) renderOverview(h int) string { Render(lipgloss.JoinVertical(lipgloss.Left, rows...)) // ── Правая панель: карта кусочков ── - gridTitle := sectionTitle.Render(fmt.Sprintf("КАРТА КУСОЧКОВ (%d шт.)", m.status.PieceCount)) + gridTitle := StyleSectionTitle.Render(fmt.Sprintf("КАРТА КУСОЧКОВ (%d шт.)", m.status.PieceCount)) gridW := rightW - 6 // отступы панели gridH := h - 7 // заголовок + легенда + отступы gridStr := m.renderPiecesGrid(gridW, gridH) legend := lipgloss.JoinHorizontal(lipgloss.Center, - lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)).Render("█ скачано "), - lipgloss.NewStyle().Foreground(lipgloss.Color(ColorYellow)).Render("█ качается "), - lipgloss.NewStyle().Foreground(lipgloss.Color(ColorBorder)).Render("░ отсутствует"), + StylePieceCompleted.Render("█ скачано "), + StylePieceDownloading.Render("█ качается "), + StylePieceMissing.Render("░ отсутствует"), ) rightInner := lipgloss.JoinVertical(lipgloss.Left, @@ -1627,20 +1915,16 @@ func (m model) renderPiecesGrid(w, h int) string { } } - cGreen := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)) - cYellow := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorYellow)) - cGray := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorBorder)) - var sb strings.Builder for cell := 0; cell < cellCount; cell++ { state := cellState(pieces, cell, piecesPerCell) switch state { case PieceCompleted: - sb.WriteString(cGreen.Render("█")) + sb.WriteString(StylePieceCompleted.Render("█")) case PieceDownloading: - sb.WriteString(cYellow.Render("▓")) + sb.WriteString(StylePieceDownloading.Render("▓")) default: - sb.WriteString(cGray.Render("░")) + sb.WriteString(StylePieceMissing.Render("░")) } if (cell+1)%w == 0 && cell < cellCount-1 { sb.WriteString("\n") @@ -1651,28 +1935,17 @@ func (m model) renderPiecesGrid(w, h int) string { func (m model) renderHelpBar() string { k := func(key, desc string) string { - return lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Bold(true).Render(key) + - lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)).Render(" "+desc) + return StyleHelpKey.Render(key) + StyleHelpDesc.Render(" "+desc) } parts := []string{ - k("Tab/1-7", "вкладки"), - k("J", "журнал"), - k("L", "логи"), - k("?", "инфо"), - k("V", "стриминг"), + k("Tab/1-2", "вкладки"), + k("Enter", "приоритет"), k("Пробел", "пауза/старт"), - k("O", "открыть"), - k("C", "очистить"), + k("?", "дополнительно"), k("Q", "выйти"), } - line := strings.Join(parts, lipgloss.NewStyle().Foreground(lipgloss.Color(ColorBorder)).Render(" │ ")) - return lipgloss.NewStyle(). - Foreground(lipgloss.Color(ColorSubdued)). - Border(lipgloss.NormalBorder(), true, false, false, false). - BorderForeground(lipgloss.Color(ColorBorder)). - MarginTop(1). - PaddingTop(0). - Render(line) + line := strings.Join(parts, cachedHelpSep) + return StyleHelpBar.Render(line) } // ── Таблицы ─────────────────────────────────────────────────────────────────── @@ -2005,7 +2278,7 @@ func Start(controller *appcore.Controller) { if err := validateInputs(torrentPath, outputDir); err == nil { m.torrentPath = torrentPath m.outputDir = outputDir - m.welcomeMode = false + m.mainMenuMode = false m.loadingMetadata = true m.inputs[0].SetValue(torrentPath) m.inputs[1].SetValue(outputDir) @@ -2014,7 +2287,7 @@ func Start(controller *appcore.Controller) { p := tea.NewProgram(m, tea.WithAltScreen()) - if !m.welcomeMode && m.loadingMetadata { + if !m.mainMenuMode && m.loadingMetadata { go func() { time.Sleep(150 * time.Millisecond) p.Send(startTorrentResultMsg{