package torrent import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "io" "net" "os" "path/filepath" "sort" "strconv" "strings" "sync" "time" "unicode" "github.com/veggiedefender/torrent-client/internal/torrentfile" "github.com/veggiedefender/torrent-client/internal/tracker" ) type Engine struct { mu sync.RWMutex torrent *torrentfile.TorrentFile peers []PeerStatus trackers []TrackerStatus peerID [20]byte cancel context.CancelFunc lastErr error phase string downloadedBytes int64 completedPieces int totalPieces int outputPath string } type Status struct { Loaded bool Name string Length int PieceLength int PieceCount int CompletedPieces int DownloadedBytes int64 TotalBytes int64 OutputPath string Files []torrentfile.File Announce string PeerCount int Peers []PeerStatus Trackers []TrackerStatus PeerID string Progress float64 Phase string LastError string } type PeerStatus struct { Address string Port uint16 Source string State string Error string DownloadedPieces int } type TrackerStatus struct { URL string State string PeerCount int Error string } const ( trackerPort = 6881 trackerNumWant = 200 trackerAnnounceTimeout = 6 * time.Second downloadStallTimeout = 90 * time.Second reannounceInterval = 25 * time.Second idleRetryDelay = 2 * time.Second ) func NewEngine() *Engine { return &Engine{ peerID: generatePeerID(), phase: "idle", } } func (e *Engine) LoadTorrent(path string) error { e.Stop() e.resetStateForNewLoad() e.setPhase("loading_metadata") tf, err := torrentfile.Open(path) if err != nil { e.setError(err) e.setPhase("failed") return err } downloadCtx, cancel := context.WithCancel(context.Background()) e.swapCancel(cancel) queryCtx, queryCancel := context.WithTimeout(downloadCtx, 30*time.Second) defer queryCancel() e.setPhase("querying_trackers") peers, trackerStatuses := e.queryTrackers(queryCtx, tf, tracker.AnnounceOptions{ PeerID: e.peerID, Port: trackerPort, NumWant: trackerNumWant, Timeout: trackerAnnounceTimeout, }) e.mu.Lock() e.torrent = tf e.trackers = trackerStatuses e.peers = peers e.totalPieces = len(tf.PieceHashes) e.downloadedBytes = 0 e.completedPieces = 0 e.outputPath = buildOutputPath(tf, "downloads") if len(peers) == 0 { warnErr := fmt.Errorf("no peers yet via %d trackers", len(trackerStatuses)) if trErr := firstTrackerError(trackerStatuses); trErr != nil { warnErr = fmt.Errorf("no peers yet: %w", trErr) } e.lastErr = warnErr } else { e.lastErr = nil } e.phase = "ready" e.mu.Unlock() go e.runDownload(downloadCtx, tf) return nil } func (e *Engine) Stop() { e.mu.Lock() cancel := e.cancel e.cancel = nil if cancel != nil { e.phase = "stopped" } e.mu.Unlock() if cancel != nil { cancel() } } func (e *Engine) Progress() float64 { e.mu.RLock() defer e.mu.RUnlock() return e.progressUnsafe() } func (e *Engine) Status() Status { e.mu.RLock() defer e.mu.RUnlock() status := Status{ Loaded: e.torrent != nil, PeerCount: len(e.peers), Peers: append([]PeerStatus(nil), e.peers...), Trackers: append([]TrackerStatus(nil), e.trackers...), PeerID: string(e.peerID[:]), Progress: e.progressUnsafe(), Phase: e.phase, CompletedPieces: e.completedPieces, DownloadedBytes: e.downloadedBytes, OutputPath: e.outputPath, } if e.torrent != nil { status.Name = e.torrent.Name status.Length = e.torrent.Length status.TotalBytes = int64(e.torrent.Length) status.PieceLength = e.torrent.PieceLength status.PieceCount = len(e.torrent.PieceHashes) status.Files = append(status.Files, e.torrent.Files...) status.Announce = e.torrent.Announce } if e.lastErr != nil { status.LastError = e.lastErr.Error() } return status } func (e *Engine) queryTrackers(ctx context.Context, tf *torrentfile.TorrentFile, opts tracker.AnnounceOptions) ([]PeerStatus, []TrackerStatus) { trackersToTry := collectTrackersToTry(tf) peerMap := make(map[string]PeerStatus) statuses := make([]TrackerStatus, 0, len(trackersToTry)) for _, announce := range trackersToTry { perTrackerCtx, cancel := context.WithTimeout(ctx, opts.Timeout) peers, err := tracker.GetPeersFromURL(perTrackerCtx, announce, tf, opts) cancel() st := TrackerStatus{URL: announce} if err != nil { st.State = "error" st.Error = err.Error() statuses = append(statuses, st) continue } if len(peers) == 0 { st.State = "empty" } else { st.State = "ok" st.PeerCount = len(peers) } statuses = append(statuses, st) for _, peer := range peers { addPeer(peerMap, peer, announce) } } peerList := make([]PeerStatus, 0, len(peerMap)) for _, peer := range peerMap { peerList = append(peerList, peer) } sort.Slice(peerList, func(i, j int) bool { if peerList[i].Address == peerList[j].Address { return peerList[i].Port < peerList[j].Port } return peerList[i].Address < peerList[j].Address }) return peerList, statuses } func (e *Engine) runDownload(ctx context.Context, tf *torrentfile.TorrentFile) { defer e.clearCancel() e.setPhase("preparing_download") partPath, partFile, err := createPartFile(tf) if err != nil { e.setTerminalError("failed", fmt.Errorf("create temp file: %w", err)) return } defer partFile.Close() pieceDone := make([]bool, len(tf.PieceHashes)) e.setPhase("downloading") lastProgressAt := time.Now() lastReannounceAt := time.Now() for !allPiecesDone(pieceDone) { if err := ctx.Err(); err != nil { e.setPhase("stopped") e.setError(nil) return } peers := e.snapshotPeers() if len(peers) == 0 { e.setPhase("querying_trackers") refreshCtx, cancel := context.WithTimeout(ctx, 20*time.Second) _ = e.refreshPeersFromTrackers(refreshCtx, tf) cancel() lastReannounceAt = time.Now() e.setPhase("downloading") peers = e.snapshotPeers() } madeProgress := false for _, peer := range peers { if allPiecesDone(pieceDone) { break } if err := ctx.Err(); err != nil { e.setPhase("stopped") e.setError(nil) return } e.setPeerState(peer.Address, peer.Port, "connecting", "") pieces, _, err := downloadFromPeer(ctx, tf, peer, e.peerID, partFile, pieceDone, func(pieceIndex int, pieceSize int) { e.recordPieceComplete(peer.Address, peer.Port, pieceSize) }) if err != nil { e.setPeerState(peer.Address, peer.Port, "error", err.Error()) continue } if pieces > 0 { madeProgress = true e.setPeerState(peer.Address, peer.Port, "active", "") } else { e.setPeerState(peer.Address, peer.Port, "idle", "") } } if madeProgress { lastProgressAt = time.Now() continue } if time.Since(lastReannounceAt) >= reannounceInterval { e.setPhase("querying_trackers") refreshCtx, cancel := context.WithTimeout(ctx, 20*time.Second) _ = e.refreshPeersFromTrackers(refreshCtx, tf) cancel() lastReannounceAt = time.Now() e.setPhase("downloading") } if time.Since(lastProgressAt) >= downloadStallTimeout { e.setTerminalError("stalled", fmt.Errorf("download stalled: no piece progress for %s", downloadStallTimeout.Round(time.Second))) return } if err := sleepWithContext(ctx, idleRetryDelay); err != nil { e.setPhase("stopped") e.setError(nil) return } } e.setPhase("writing_files") if err := materializeDownloadedFiles(tf, partPath, "downloads"); err != nil { e.setTerminalError("failed", fmt.Errorf("write output files: %w", err)) return } _ = os.Remove(partPath) e.mu.Lock() e.downloadedBytes = int64(tf.Length) e.completedPieces = len(tf.PieceHashes) e.phase = "completed" e.lastErr = nil e.mu.Unlock() } func (e *Engine) snapshotPeers() []PeerStatus { e.mu.RLock() defer e.mu.RUnlock() return append([]PeerStatus(nil), e.peers...) } func (e *Engine) refreshPeersFromTrackers(ctx context.Context, tf *torrentfile.TorrentFile) int { peers, trackerStatuses := e.queryTrackers(ctx, tf, tracker.AnnounceOptions{ PeerID: e.peerID, Port: trackerPort, Downloaded: e.currentDownloadedBytes(), NumWant: trackerNumWant, Timeout: trackerAnnounceTimeout, }) e.mu.Lock() defer e.mu.Unlock() if len(trackerStatuses) > 0 { e.trackers = trackerStatuses } if len(peers) == 0 { waitErr := fmt.Errorf("waiting for peers via %d trackers", len(trackerStatuses)) if trErr := firstTrackerError(trackerStatuses); trErr != nil { waitErr = fmt.Errorf("waiting for peers: %w", trErr) } e.lastErr = waitErr return 0 } e.lastErr = nil seen := make(map[string]struct{}, len(e.peers)) for _, existingPeer := range e.peers { seen[peerStatusKey(existingPeer.Address, existingPeer.Port)] = struct{}{} } added := 0 for _, peer := range peers { key := peerStatusKey(peer.Address, peer.Port) if _, ok := seen[key]; ok { continue } e.peers = append(e.peers, peer) seen[key] = struct{}{} added++ } if added > 0 { sort.Slice(e.peers, func(i, j int) bool { if e.peers[i].Address == e.peers[j].Address { return e.peers[i].Port < e.peers[j].Port } return e.peers[i].Address < e.peers[j].Address }) } return added } func (e *Engine) currentDownloadedBytes() int64 { e.mu.RLock() defer e.mu.RUnlock() return e.downloadedBytes } func (e *Engine) recordPieceComplete(address string, port uint16, pieceSize int) { e.mu.Lock() defer e.mu.Unlock() e.downloadedBytes += int64(pieceSize) e.completedPieces++ for i := range e.peers { if e.peers[i].Address == address && e.peers[i].Port == port { e.peers[i].DownloadedPieces++ break } } } func (e *Engine) setPeerState(address string, port uint16, state, errText string) { e.mu.Lock() defer e.mu.Unlock() for i := range e.peers { if e.peers[i].Address == address && e.peers[i].Port == port { e.peers[i].State = state e.peers[i].Error = errText break } } } func (e *Engine) setTerminalError(phase string, err error) { e.mu.Lock() defer e.mu.Unlock() e.phase = phase e.lastErr = err } func (e *Engine) progressUnsafe() float64 { if e.torrent != nil && e.torrent.Length > 0 { if e.phase == "completed" { return 1 } if e.downloadedBytes > 0 { progress := float64(e.downloadedBytes) / float64(e.torrent.Length) if progress > 1 { return 1 } return progress } } switch e.phase { case "idle", "failed", "stopped", "stalled", "tracker_errors": return 0 case "loading_metadata": return 0.05 case "querying_trackers": return 0.12 case "ready": return 0.2 case "preparing_download": return 0.25 case "downloading": return 0.3 case "writing_files": return 0.95 default: return 0 } } func (e *Engine) resetStateForNewLoad() { e.mu.Lock() defer e.mu.Unlock() e.torrent = nil e.peers = nil e.trackers = nil e.lastErr = nil e.phase = "idle" e.downloadedBytes = 0 e.completedPieces = 0 e.totalPieces = 0 e.outputPath = "" } func (e *Engine) setError(err error) { e.mu.Lock() defer e.mu.Unlock() e.lastErr = err } func (e *Engine) setPhase(phase string) { e.mu.Lock() defer e.mu.Unlock() e.phase = phase } func (e *Engine) swapCancel(cancel context.CancelFunc) { e.mu.Lock() defer e.mu.Unlock() if e.cancel != nil { e.cancel() } e.cancel = cancel } func (e *Engine) clearCancel() { e.mu.Lock() defer e.mu.Unlock() if e.cancel != nil { e.cancel() e.cancel = nil } } func generatePeerID() [20]byte { const prefix = "-ZT0001-" const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz" var id [20]byte copy(id[:], prefix) buf := make([]byte, len(id)-len(prefix)) if _, err := rand.Read(buf); err != nil { now := time.Now().UnixNano() for i := range buf { buf[i] = byte(now >> (i * 8)) } } for i, b := range buf { id[len(prefix)+i] = alphabet[int(b)%len(alphabet)] } return id } func addPeer(peerMap map[string]PeerStatus, peer tracker.Peer, source string) { key := peerStatusKey(peer.IP.String(), peer.Port) if _, exists := peerMap[key]; exists { return } peerMap[key] = PeerStatus{ Address: peer.IP.String(), Port: peer.Port, Source: source, State: "discovered", } } func peerStatusKey(address string, port uint16) string { return net.JoinHostPort(address, strconv.Itoa(int(port))) } func collectTrackersToTry(tf *torrentfile.TorrentFile) []string { const fallbackTrackers = ` udp://open.stealth.si:80/announce udp://tracker.opentrackr.org:1337/announce udp://tracker.openbittorrent.com:6969/announce udp://tracker.torrent.eu.org:451/announce https://tracker.opentrackr.org:443/announce http://tracker.opentrackr.org:1337/announce ` seen := make(map[string]struct{}) list := make([]string, 0, len(tf.Trackers)+8) add := func(tr string) { if tr == "" { return } if _, ok := seen[tr]; ok { return } seen[tr] = struct{}{} list = append(list, tr) } for _, tr := range tf.Trackers { add(tr) } for _, tr := range splitLines(fallbackTrackers) { add(tr) } return list } func splitLines(s string) []string { parts := strings.Split(s, "\n") out := make([]string, 0, len(parts)) for _, part := range parts { trimmed := strings.TrimSpace(part) if trimmed == "" { continue } out = append(out, trimmed) } return out } func firstTrackerError(statuses []TrackerStatus) error { for _, st := range statuses { if st.State == "error" && st.Error != "" { return errors.New(st.Error) } } return nil } func allPiecesDone(pieceDone []bool) bool { for _, done := range pieceDone { if !done { return false } } return true } func sleepWithContext(ctx context.Context, d time.Duration) error { timer := time.NewTimer(d) defer timer.Stop() select { case <-ctx.Done(): return ctx.Err() case <-timer.C: return nil } } func createPartFile(tf *torrentfile.TorrentFile) (string, *os.File, error) { partsDir := filepath.Join("downloads", ".parts") if err := os.MkdirAll(partsDir, 0o755); err != nil { return "", nil, err } hashPrefix := hex.EncodeToString(tf.InfoHash[:6]) name := sanitizePathPart(tf.Name) + "-" + hashPrefix + ".part" partPath := filepath.Join(partsDir, name) f, err := os.OpenFile(partPath, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0o644) if err != nil { return "", nil, err } if err := f.Truncate(int64(tf.Length)); err != nil { f.Close() return "", nil, err } return partPath, f, nil } func materializeDownloadedFiles(tf *torrentfile.TorrentFile, partPath, outputRoot string) error { partFile, err := os.Open(partPath) if err != nil { return err } defer partFile.Close() if err := os.MkdirAll(outputRoot, 0o755); err != nil { return err } offset := int64(0) for _, f := range tf.Files { targetPath, err := safeOutputPath(outputRoot, f.Path) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { return err } out, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) if err != nil { return err } _, copyErr := io.CopyN(out, io.NewSectionReader(partFile, offset, int64(f.Length)), int64(f.Length)) closeErr := out.Close() if copyErr != nil { return copyErr } if closeErr != nil { return closeErr } offset += int64(f.Length) } if offset != int64(tf.Length) { return fmt.Errorf("written bytes mismatch: expected %d, wrote %d", tf.Length, offset) } return nil } func safeOutputPath(root, rel string) (string, error) { clean := filepath.Clean(filepath.FromSlash(rel)) if clean == "." || clean == "" { return "", errors.New("invalid output file path") } if filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") { return "", fmt.Errorf("unsafe output path %q", rel) } return filepath.Join(root, clean), nil } func buildOutputPath(tf *torrentfile.TorrentFile, outputRoot string) string { if len(tf.Files) == 1 { if path, err := safeOutputPath(outputRoot, tf.Files[0].Path); err == nil { return path } } return filepath.Join(outputRoot, sanitizePathPart(tf.Name)) } func sanitizePathPart(s string) string { s = strings.TrimSpace(s) if s == "" { return "torrent" } var b strings.Builder for _, r := range s { switch { case unicode.IsLetter(r), unicode.IsDigit(r), r == '-', r == '_', r == '.': b.WriteRune(r) default: b.WriteByte('_') } } out := strings.Trim(b.String(), "._") if out == "" { return "torrent" } return out }