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 }