142 lines
3.1 KiB
Go
142 lines
3.1 KiB
Go
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
|
|
}
|