package streaming import ( "context" "fmt" "log/slog" "net/http" "net/url" "os" "path/filepath" "strings" "sync" "time" "github.com/anacrolix/torrent" "github.com/anacrolix/torrent/metainfo" "golang.org/x/net/proxy" "mal/internal/nyaa" ) type Service struct { client *torrent.Client nyaa *nyaa.Client hls *HLSTranscoder mu sync.RWMutex activeTorrents map[string]*torrent.Torrent logger *slog.Logger } type StreamInfo struct { InfoHash string Name string Size int64 Files []FileInfo Progress float64 DownloadRate int64 Peers int } type FileInfo struct { Index int Path string Size int64 } func NewService(logger *slog.Logger) (*Service, error) { cfg := torrent.NewDefaultClientConfig() cfg.ListenPort = 42069 cfg.Seed = false cfg.NoUpload = true // Use temp directory for downloads cfg.DataDir = filepath.Join("/tmp", "mal-streams") // Configure SOCKS5 proxy if TORRENT_PROXY is set // Usage: export TORRENT_PROXY=socks5://127.0.0.1:1080 // Start with: ssh -D 1080 -N user@your-vps if proxyURL := os.Getenv("TORRENT_PROXY"); proxyURL != "" { parsed, err := url.Parse(proxyURL) if err != nil { return nil, fmt.Errorf("invalid TORRENT_PROXY url: %w", err) } dialer, err := proxy.FromURL(parsed, proxy.Direct) if err != nil { return nil, fmt.Errorf("failed to create proxy dialer: %w", err) } // Proxy HTTP requests (trackers, webseeds) cfg.HTTPProxy = func(*http.Request) (*url.URL, error) { return parsed, nil } // Proxy peer connections via DialContext if contextDialer, ok := dialer.(proxy.ContextDialer); ok { cfg.HTTPDialContext = contextDialer.DialContext } logger.Info("torrent proxy configured", "proxy", proxyURL) } client, err := torrent.NewClient(cfg) if err != nil { return nil, fmt.Errorf("failed to create torrent client: %w", err) } hls, err := NewHLSTranscoder(logger) if err != nil { logger.Warn("HLS transcoding unavailable", "error", err) // Continue without HLS - will fall back to direct streaming } return &Service{ client: client, nyaa: nyaa.NewClient(), hls: hls, activeTorrents: make(map[string]*torrent.Torrent), logger: logger, }, nil } // SearchEpisode searches nyaa for torrents of a specific episode func (s *Service) SearchEpisode(animeTitle string, episode int) ([]nyaa.Torrent, error) { return s.nyaa.SearchEpisode(animeTitle, episode) } // SearchAnime searches nyaa for torrents of an anime func (s *Service) SearchAnime(query string) ([]nyaa.Torrent, error) { return s.nyaa.SearchAnime(query) } // AddMagnet adds a magnet link and returns stream info func (s *Service) AddMagnet(ctx context.Context, magnetURI string) (*StreamInfo, error) { t, err := s.client.AddMagnet(magnetURI) if err != nil { return nil, fmt.Errorf("failed to add magnet: %w", err) } // Wait for metadata with timeout select { case <-t.GotInfo(): case <-ctx.Done(): t.Drop() return nil, ctx.Err() case <-time.After(60 * time.Second): t.Drop() return nil, fmt.Errorf("timeout waiting for torrent metadata") } infoHash := t.InfoHash().HexString() s.mu.Lock() s.activeTorrents[infoHash] = t s.mu.Unlock() return s.getStreamInfo(t), nil } // GetTorrent returns an active torrent by info hash func (s *Service) GetTorrent(infoHash string) (*torrent.Torrent, bool) { s.mu.RLock() defer s.mu.RUnlock() t, ok := s.activeTorrents[infoHash] return t, ok } // StreamFile streams a specific file from a torrent func (s *Service) StreamFile(w http.ResponseWriter, r *http.Request, infoHash string, fileIdx int) error { t, ok := s.GetTorrent(infoHash) if !ok { return fmt.Errorf("torrent not found: %s", infoHash) } files := t.Files() if fileIdx < 0 || fileIdx >= len(files) { return fmt.Errorf("invalid file index: %d", fileIdx) } file := files[fileIdx] reader := file.NewReader() reader.SetReadahead(file.Length() / 100) // 1% readahead reader.SetResponsive() // Determine content type contentType := "application/octet-stream" ext := strings.ToLower(filepath.Ext(file.Path())) switch ext { case ".mp4": contentType = "video/mp4" case ".mkv": contentType = "video/x-matroska" case ".webm": contentType = "video/webm" case ".avi": contentType = "video/x-msvideo" } w.Header().Set("Content-Type", contentType) w.Header().Set("Accept-Ranges", "bytes") // Handle range requests for seeking http.ServeContent(w, r, file.Path(), time.Time{}, reader) return nil } // StreamVideo finds and streams the main video file from a torrent func (s *Service) StreamVideo(w http.ResponseWriter, r *http.Request, infoHash string) error { t, ok := s.GetTorrent(infoHash) if !ok { return fmt.Errorf("torrent not found: %s", infoHash) } // Find the largest video file var bestFile *torrent.File var bestIdx int for i, f := range t.Files() { if isVideoFile(f.Path()) { if bestFile == nil || f.Length() > bestFile.Length() { bestFile = f bestIdx = i } } } if bestFile == nil { return fmt.Errorf("no video file found in torrent") } return s.StreamFile(w, r, infoHash, bestIdx) } // GetStreamInfo returns info about an active torrent func (s *Service) GetStreamInfo(infoHash string) (*StreamInfo, error) { t, ok := s.GetTorrent(infoHash) if !ok { return nil, fmt.Errorf("torrent not found: %s", infoHash) } return s.getStreamInfo(t), nil } func (s *Service) getStreamInfo(t *torrent.Torrent) *StreamInfo { info := &StreamInfo{ InfoHash: t.InfoHash().HexString(), Name: t.Name(), Peers: t.Stats().ActivePeers, } var totalLength, completed int64 for i, f := range t.Files() { info.Files = append(info.Files, FileInfo{ Index: i, Path: f.Path(), Size: f.Length(), }) totalLength += f.Length() completed += f.BytesCompleted() } info.Size = totalLength if totalLength > 0 { info.Progress = float64(completed) / float64(totalLength) * 100 } stats := t.Stats() info.DownloadRate = stats.ConnStats.BytesReadData.Int64() return info } // DropTorrent removes a torrent from the client func (s *Service) DropTorrent(infoHash string) { s.mu.Lock() defer s.mu.Unlock() if t, ok := s.activeTorrents[infoHash]; ok { t.Drop() delete(s.activeTorrents, infoHash) } } // Close shuts down the torrent client func (s *Service) Close() { s.mu.Lock() for _, t := range s.activeTorrents { t.Drop() } s.activeTorrents = nil s.mu.Unlock() if s.hls != nil { s.hls.Shutdown() } s.client.Close() } func isVideoFile(path string) bool { videoExts := []string{".mp4", ".mkv", ".avi", ".mov", ".webm", ".wmv", ".flv"} lower := strings.ToLower(path) for _, ext := range videoExts { if strings.HasSuffix(lower, ext) { return true } } return false } // findEpisodeFile finds the video file matching a specific episode number // Falls back to largest video file if no match found func findEpisodeFile(files []*torrent.File, episode int) *torrent.File { var bestMatch *torrent.File var fallback *torrent.File // Episode patterns to match in filenames epStr := fmt.Sprintf("%02d", episode) epStr2 := fmt.Sprintf("%d", episode) patterns := []string{ fmt.Sprintf(" - %s", epStr), // - 01 fmt.Sprintf(" - %s ", epStr), // - 01 (with space after) fmt.Sprintf("E%s", epStr), // E01 fmt.Sprintf("E%s ", epStr), // E01 (with space) fmt.Sprintf("Episode %s", epStr2), // Episode 1 fmt.Sprintf("Episode %s", epStr), // Episode 01 fmt.Sprintf(" %s ", epStr), // standalone 01 fmt.Sprintf("[%s]", epStr), // [01] fmt.Sprintf("_%s_", epStr), // _01_ fmt.Sprintf(".%s.", epStr), // .01. } for _, f := range files { if !isVideoFile(f.Path()) { continue } // Track largest video as fallback if fallback == nil || f.Length() > fallback.Length() { fallback = f } filename := strings.ToLower(filepath.Base(f.Path())) // Check each pattern for _, pattern := range patterns { if strings.Contains(filename, strings.ToLower(pattern)) { // Verify it's not a different episode (e.g., searching for ep 1, don't match ep 10) // Check character after match isn't a digit idx := strings.Index(filename, strings.ToLower(pattern)) if idx >= 0 { afterIdx := idx + len(pattern) if afterIdx >= len(filename) || !isDigit(filename[afterIdx]) { if bestMatch == nil || f.Length() > bestMatch.Length() { bestMatch = f } break } } } } } if bestMatch != nil { return bestMatch } return fallback } func isDigit(c byte) bool { return c >= '0' && c <= '9' } // ParseMagnetHash extracts info hash from a magnet URI func ParseMagnetHash(magnetURI string) (string, error) { spec, err := torrent.TorrentSpecFromMagnetUri(magnetURI) if err != nil { return "", err } return spec.InfoHash.HexString(), nil } // MagnetFromHash creates a minimal magnet URI from an info hash func MagnetFromHash(infoHash string) (string, error) { var ih metainfo.Hash if err := ih.FromHexString(infoHash); err != nil { return "", err } return fmt.Sprintf("magnet:?xt=urn:btih:%s", ih.HexString()), nil } // GetVideoFilePath returns the filesystem path to the main video file func (s *Service) GetVideoFilePath(infoHash string) (string, error) { t, ok := s.GetTorrent(infoHash) if !ok { return "", fmt.Errorf("torrent not found: %s", infoHash) } // Find the largest video file var bestFile *torrent.File for _, f := range t.Files() { if isVideoFile(f.Path()) { if bestFile == nil || f.Length() > bestFile.Length() { bestFile = f } } } if bestFile == nil { return "", fmt.Errorf("no video file found in torrent") } // Return path relative to data dir return filepath.Join("/tmp", "mal-streams", bestFile.Path()), nil } // StartHLS starts HLS transcoding for a torrent // If episode > 0, it will try to find the file matching that episode number func (s *Service) StartHLS(ctx context.Context, infoHash string, episode int) (*HLSSession, error) { if s.hls == nil { return nil, fmt.Errorf("HLS transcoding not available (ffmpeg not found)") } // Use episode-specific session key if episode specified sessionKey := infoHash if episode > 0 { sessionKey = fmt.Sprintf("%s-ep%d", infoHash, episode) } // Check if session already exists if session, ok := s.hls.GetSession(sessionKey); ok { return session, nil } // Get torrent and video file t, ok := s.GetTorrent(infoHash) if !ok { return nil, fmt.Errorf("torrent not found: %s", infoHash) } // Find the video file - either by episode or largest var videoFile *torrent.File if episode > 0 { videoFile = findEpisodeFile(t.Files(), episode) if videoFile != nil { s.logger.Info("found episode file", "episode", episode, "file", videoFile.Path()) } } // Fallback to largest video file if videoFile == nil { for _, f := range t.Files() { if isVideoFile(f.Path()) { if videoFile == nil || f.Length() > videoFile.Length() { videoFile = f } } } } if videoFile == nil { return nil, fmt.Errorf("no video file found in torrent") } // Prioritize downloading the beginning of the file for ffmpeg videoFile.Download() reader := videoFile.NewReader() reader.SetReadahead(10 * 1024 * 1024) // 10MB readahead reader.SetResponsive() // Wait for at least 2MB to be available before starting ffmpeg minBytes := int64(2 * 1024 * 1024) s.logger.Info("waiting for initial data", "hash", infoHash, "file", videoFile.Path(), "need", minBytes) ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() timeout := time.After(60 * time.Second) for { select { case <-ctx.Done(): return nil, ctx.Err() case <-timeout: return nil, fmt.Errorf("timeout waiting for video data") case <-ticker.C: completed := videoFile.BytesCompleted() if completed >= minBytes { s.logger.Info("got enough data, starting transcoding", "hash", infoHash, "bytes", completed) goto ready } s.logger.Debug("waiting for data", "hash", infoHash, "completed", completed, "need", minBytes) } } ready: // Start transcoding with the reader piped to ffmpeg session, err := s.hls.StartSessionWithReader(sessionKey, reader) if err != nil { return nil, err } // Wait for first segments to be ready waitCtx, cancel := context.WithTimeout(ctx, 60*time.Second) defer cancel() if err := session.WaitReady(waitCtx); err != nil { s.hls.StopSession(sessionKey) return nil, fmt.Errorf("HLS not ready: %w", err) } return session, nil } // GetHLSSession returns an existing HLS session func (s *Service) GetHLSSession(infoHash string) (*HLSSession, bool) { if s.hls == nil { return nil, false } return s.hls.GetSession(infoHash) } // HasHLS returns whether HLS transcoding is available func (s *Service) HasHLS() bool { return s.hls != nil }