package directstream import ( "context" "errors" "fmt" "io" "net/http" "path/filepath" "seanime/internal/api/anilist" hibiketorrent "seanime/internal/extension/hibike/torrent" "seanime/internal/library/anime" "seanime/internal/mkvparser" "seanime/internal/nativeplayer" "seanime/internal/util" httputil "seanime/internal/util/http" "seanime/internal/util/result" "strconv" "strings" "sync" "time" "github.com/google/uuid" "github.com/samber/mo" ) //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Torrent //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// var _ Stream = (*DebridStream)(nil) // DebridStream is a stream that is a torrent. type DebridStream struct { BaseStream streamUrl string contentLength int64 torrent *hibiketorrent.AnimeTorrent streamReadyCh chan struct{} // Closed by the initiator when the stream is ready httpStream *httputil.FileStream // Shared file-backed cache for multiple readers cacheMu sync.RWMutex // Protects httpStream access } func (s *DebridStream) Type() nativeplayer.StreamType { return nativeplayer.StreamTypeDebrid } func (s *DebridStream) LoadContentType() string { s.contentTypeOnce.Do(func() { s.cacheMu.RLock() if s.httpStream == nil { s.cacheMu.RUnlock() _ = s.initializeStream() } else { s.cacheMu.RUnlock() } info, ok := s.FetchStreamInfo(s.streamUrl) if !ok { s.logger.Warn().Str("url", s.streamUrl).Msg("directstream(debrid): Failed to fetch stream info for content type") return } s.logger.Debug().Str("url", s.streamUrl).Str("contentType", info.ContentType).Int64("contentLength", info.ContentLength).Msg("directstream(debrid): Fetched content type and length") s.contentType = info.ContentType if s.contentType == "application/force-download" { s.contentType = "application/octet-stream" } s.contentLength = info.ContentLength }) return s.contentType } // Close cleanup the HTTP cache and other resources func (s *DebridStream) Close() error { s.cacheMu.Lock() defer s.cacheMu.Unlock() s.logger.Debug().Msg("directstream(debrid): Closing HTTP cache") if s.httpStream != nil { if err := s.httpStream.Close(); err != nil { s.logger.Error().Err(err).Msg("directstream(debrid): Failed to close HTTP cache") return err } s.httpStream = nil } s.logger.Debug().Msg("directstream(debrid): HTTP cache closed successfully") return nil } // Terminate overrides BaseStream.Terminate to also clean up the HTTP cache func (s *DebridStream) Terminate() { // Clean up HTTP cache first if err := s.Close(); err != nil { s.logger.Error().Err(err).Msg("directstream(debrid): Failed to clean up HTTP cache during termination") } // Call the base implementation s.BaseStream.Terminate() } func (s *DebridStream) LoadPlaybackInfo() (ret *nativeplayer.PlaybackInfo, err error) { s.playbackInfoOnce.Do(func() { if s.streamUrl == "" { ret = &nativeplayer.PlaybackInfo{} err = fmt.Errorf("stream url is not set") s.playbackInfoErr = err return } id := uuid.New().String() var entryListData *anime.EntryListData if animeCollection, ok := s.manager.animeCollection.Get(); ok { if listEntry, ok := animeCollection.GetListEntryFromAnimeId(s.media.ID); ok { entryListData = anime.NewEntryListData(listEntry) } } contentType := s.LoadContentType() playbackInfo := nativeplayer.PlaybackInfo{ ID: id, StreamType: s.Type(), MimeType: contentType, StreamUrl: "{{SERVER_URL}}/api/v1/directstream/stream?id=" + id, ContentLength: s.contentLength, // loaded by LoadContentType MkvMetadata: nil, MkvMetadataParser: mo.None[*mkvparser.MetadataParser](), Episode: s.episode, Media: s.media, EntryListData: entryListData, } // If the content type is an EBML content type, we can create a metadata parser if isEbmlContent(s.LoadContentType()) { reader, err := httputil.NewHttpReadSeekerFromURL(s.streamUrl) //reader, err := s.getPriorityReader() if err != nil { err = fmt.Errorf("failed to create reader for stream url: %w", err) s.logger.Error().Err(err).Msg("directstream(debrid): Failed to create reader for stream url") s.playbackInfoErr = err return } defer reader.Close() // Close this specific reader instance _, _ = reader.Seek(0, io.SeekStart) s.logger.Trace().Msgf( "directstream(debrid): Loading metadata for stream url: %s", s.streamUrl, ) parser := mkvparser.NewMetadataParser(reader, s.logger) metadata := parser.GetMetadata(context.Background()) if metadata.Error != nil { err = fmt.Errorf("failed to get metadata: %w", metadata.Error) s.logger.Error().Err(metadata.Error).Msg("directstream(debrid): Failed to get metadata") s.playbackInfoErr = err return } playbackInfo.MkvMetadata = metadata playbackInfo.MkvMetadataParser = mo.Some(parser) } s.playbackInfo = &playbackInfo }) return s.playbackInfo, s.playbackInfoErr } func (s *DebridStream) GetAttachmentByName(filename string) (*mkvparser.AttachmentInfo, bool) { return getAttachmentByName(s.manager.playbackCtx, s, filename) } var videoProxyClient = &http.Client{ Transport: &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 10, IdleConnTimeout: 90 * time.Second, ForceAttemptHTTP2: false, // Fixes issues on Linux }, Timeout: 60 * time.Second, } func (s *DebridStream) GetStreamHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.logger.Trace().Str("range", r.Header.Get("Range")).Str("method", r.Method).Msg("directstream(debrid): Stream endpoint hit") if s.streamUrl == "" { s.logger.Error().Msg("directstream(debrid): No URL to stream") http.Error(w, "No URL to stream", http.StatusNotFound) return } if r.Method == http.MethodHead { s.logger.Trace().Msg("directstream(debrid): Handling HEAD request") fileSize := s.contentLength w.Header().Set("Content-Length", fmt.Sprintf("%d", fileSize)) w.Header().Set("Content-Type", s.LoadContentType()) w.Header().Set("Accept-Ranges", "bytes") w.WriteHeader(http.StatusOK) return } rangeHeader := r.Header.Get("Range") if err := s.initializeStream(); err != nil { s.logger.Error().Err(err).Msg("directstream(debrid): Failed to initialize FileStream") http.Error(w, "Failed to initialize FileStream", http.StatusInternalServerError) return } reader, err := s.getReader() if err != nil { s.logger.Error().Err(err).Msg("directstream(debrid): Failed to create reader for stream url") http.Error(w, "Failed to create reader for stream url", http.StatusInternalServerError) } if isThumbnailRequest(r) { ra, ok := handleRange(w, r, reader, s.filename, s.contentLength) if !ok { return } serveContentRange(w, r, r.Context(), reader, s.filename, s.contentLength, s.contentType, ra) return } ra, ok := handleRange(w, r, reader, s.filename, s.contentLength) if !ok { return } if _, ok := s.playbackInfo.MkvMetadataParser.Get(); ok { subReader, err := s.getReader() if err != nil { s.logger.Error().Err(err).Msg("directstream(debrid): Failed to create subtitle reader for stream url") http.Error(w, "Failed to create subtitle reader for stream url", http.StatusInternalServerError) return } if ra.Start < s.contentLength-1024*1024 { go s.StartSubtitleStreamP(s, s.manager.playbackCtx, subReader, ra.Start, 0) } } req, err := http.NewRequest(http.MethodGet, s.streamUrl, nil) if err != nil { http.Error(w, "Failed to create request", http.StatusInternalServerError) return } req.Header.Set("Accept", "*/*") req.Header.Set("Range", rangeHeader) // Copy original request headers to the proxied request for key, values := range r.Header { for _, value := range values { req.Header.Add(key, value) } } resp, err := videoProxyClient.Do(req) if err != nil { http.Error(w, "Failed to proxy request", http.StatusInternalServerError) return } defer resp.Body.Close() // Copy response headers for key, values := range resp.Header { for _, value := range values { w.Header().Set(key, value) } } w.Header().Set("Content-Type", s.LoadContentType()) // overwrite the type w.WriteHeader(resp.StatusCode) _ = s.httpStream.WriteAndFlush(resp.Body, w, ra.Start) }) } //func (s *DebridStream) GetStreamHandler() http.Handler { // return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // s.logger.Trace().Str("range", r.Header.Get("Range")).Str("method", r.Method).Msg("directstream(debrid): Stream endpoint hit") // // if s.streamUrl == "" { // s.logger.Error().Msg("directstream(debrid): No URL to stream") // http.Error(w, "No URL to stream", http.StatusNotFound) // return // } // // // Handle HEAD requests explicitly to provide file size information // if r.Method == http.MethodHead { // s.logger.Trace().Msg("directstream(debrid): Handling HEAD request") // // // Set the content length from torrent file // fileSize := s.contentLength // w.Header().Set("Content-Length", fmt.Sprintf("%d", fileSize)) // w.Header().Set("Content-Type", s.LoadContentType()) // w.Header().Set("Accept-Ranges", "bytes") // w.WriteHeader(http.StatusOK) // return // } // // rangeHeader := r.Header.Get("Range") // // // Parse the range header // ranges, err := httputil.ParseRange(rangeHeader, s.contentLength) // if err != nil && !errors.Is(err, httputil.ErrNoOverlap) { // w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", s.contentLength)) // http.Error(w, "Invalid Range", http.StatusRequestedRangeNotSatisfiable) // return // } else if err != nil && errors.Is(err, httputil.ErrNoOverlap) { // // Let Go handle overlap // w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", s.contentLength)) // } // // // Initialize the FileStream // if err := s.initializeStream(); err != nil { // s.logger.Error().Err(err).Msg("directstream(debrid): Failed to initialize FileStream") // http.Error(w, "Failed to initialize FileStream", http.StatusInternalServerError) // return // } // // // Determine the offset for the HTTP request and FileStream // var httpRequestOffset int64 = 0 // var fileWriteOffset int64 = 0 // var httpResponseOffset int64 = 0 // // if len(ranges) > 0 { // originalOffset := ranges[0].Start // // Start HTTP request 1MB earlier to ensure subtitle clusters are available // const bufferSize = 1024 * 1024 // 1MB // httpRequestOffset = originalOffset - bufferSize // if httpRequestOffset < 0 { // httpRequestOffset = 0 // } // fileWriteOffset = httpRequestOffset // httpResponseOffset = originalOffset - httpRequestOffset // } // // // Update the range header for the actual HTTP request // var actualRangeHeader string // if len(ranges) > 0 { // if httpRequestOffset != ranges[0].Start { // // Create a new range header starting from the earlier offset // endOffset := ranges[0].Start + ranges[0].Length - 1 // if endOffset >= s.contentLength { // endOffset = s.contentLength - 1 // } // actualRangeHeader = fmt.Sprintf("bytes=%d-%d", httpRequestOffset, endOffset) // } else { // actualRangeHeader = rangeHeader // } // } // // // Create HTTP request for the range // req, err := http.NewRequest(http.MethodGet, s.streamUrl, nil) // if err != nil { // http.Error(w, "Failed to create request", http.StatusInternalServerError) // return // } // // w.Header().Set("Content-Type", s.LoadContentType()) // w.Header().Set("Accept-Ranges", "bytes") // w.Header().Set("Connection", "keep-alive") // w.Header().Set("Cache-Control", "no-store") // // // Copy original request headers to the proxied request // for key, values := range r.Header { // for _, value := range values { // req.Header.Add(key, value) // } // } // // req.Header.Set("Accept", "*/*") // req.Header.Set("Range", actualRangeHeader) // // // Make the HTTP request // resp, err := videoProxyClient.Do(req) // if err != nil { // http.Error(w, "Failed to proxy request", http.StatusInternalServerError) // return // } // defer resp.Body.Close() // // if _, ok := s.playbackInfo.MkvMetadataParser.Get(); ok { // // Start a subtitle stream from the current position using normal reader (no prefetching) // subReader, err := s.getReader() // if err != nil { // s.logger.Error().Err(err).Msg("directstream(debrid): Failed to create subtitle reader for stream url") // http.Error(w, "Failed to create subtitle reader for stream url", http.StatusInternalServerError) // return // } // // Do not start stream if start if 1MB from the end // if len(ranges) > 0 && ranges[0].Start < s.contentLength-1024*1024 { // go s.StartSubtitleStream(s, s.manager.playbackCtx, subReader, ranges[0].Start) // } // } // // // Copy response headers but adjust Content-Range if we modified the range // for key, values := range resp.Header { // if key == "Content-Type" { // continue // } // if key == "Content-Range" && httpResponseOffset > 0 { // // Adjust the Content-Range header to reflect the original request // continue // We'll set this manually below // } // if key == "Content-Length" && httpResponseOffset > 0 { // continue // } // for _, value := range values { // w.Header().Set(key, value) // } // } // // // Set the correct Content-Range header for the original request // if len(ranges) > 0 && httpResponseOffset > 0 { // originalRange := ranges[0] // w.Header().Set("Content-Range", originalRange.ContentRange(s.contentLength)) // w.Header().Set("Content-Length", fmt.Sprintf("%d", s.contentLength)) // } // // // Set the status code // w.WriteHeader(resp.StatusCode) // // // Create a custom writer that skips the buffer bytes for HTTP response // httpWriter := &offsetWriter{ // writer: w, // skipBytes: httpResponseOffset, // skipped: 0, // } // // // Use FileStream's WriteAndFlush to write all data to file but only desired range to HTTP response // err = s.httpStream.WriteAndFlush(resp.Body, httpWriter, fileWriteOffset) // if err != nil { // s.logger.Error().Err(err).Msg("directstream(debrid): Failed to stream response body") // http.Error(w, "Failed to stream response body", http.StatusInternalServerError) // return // } // }) //} type PlayDebridStreamOptions struct { StreamUrl string MediaId int EpisodeNumber int // RELATIVE Episode number to identify the file AnidbEpisode string // Anizip episode Media *anilist.BaseAnime Torrent *hibiketorrent.AnimeTorrent // Selected torrent FileId string // File ID or index UserAgent string ClientId string AutoSelect bool } // PlayDebridStream is used by a module to load a new torrent stream. func (m *Manager) PlayDebridStream(ctx context.Context, opts PlayDebridStreamOptions) error { m.playbackMu.Lock() defer m.playbackMu.Unlock() episodeCollection, err := anime.NewEpisodeCollection(anime.NewEpisodeCollectionOptions{ AnimeMetadata: nil, Media: opts.Media, MetadataProvider: m.metadataProvider, Logger: m.Logger, }) if err != nil { return fmt.Errorf("cannot play local file, could not create episode collection: %w", err) } episode, ok := episodeCollection.FindEpisodeByAniDB(opts.AnidbEpisode) if !ok { return fmt.Errorf("cannot play torrent stream, could not find episode: %s", opts.AnidbEpisode) } stream := &DebridStream{ streamUrl: opts.StreamUrl, torrent: opts.Torrent, BaseStream: BaseStream{ manager: m, logger: m.Logger, clientId: opts.ClientId, media: opts.Media, filename: "", episode: episode, episodeCollection: episodeCollection, subtitleEventCache: result.NewResultMap[string, *mkvparser.SubtitleEvent](), activeSubtitleStreams: result.NewResultMap[string, *SubtitleStream](), }, streamReadyCh: make(chan struct{}), } go func() { m.loadStream(stream) }() return nil } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // initializeStream creates the HTTP cache for this stream if it doesn't exist func (s *DebridStream) initializeStream() error { s.cacheMu.Lock() defer s.cacheMu.Unlock() if s.httpStream != nil { return nil // Already initialized } if s.streamUrl == "" { return fmt.Errorf("stream URL is not set") } // Get content length first if s.contentLength == 0 { info, ok := s.FetchStreamInfo(s.streamUrl) if !ok { return fmt.Errorf("failed to fetch stream info") } s.contentLength = info.ContentLength } s.logger.Debug().Msgf("directstream(debrid): Initializing FileStream for stream URL: %s", s.streamUrl) // Create a file-backed stream with the known content length cache, err := httputil.NewFileStream(s.manager.playbackCtx, s.logger, s.contentLength) if err != nil { return fmt.Errorf("failed to create FileStream: %w", err) } s.httpStream = cache s.logger.Debug().Msgf("directstream(debrid): FileStream initialized") return nil } func (s *DebridStream) getReader() (io.ReadSeekCloser, error) { if err := s.initializeStream(); err != nil { return nil, err } s.cacheMu.RLock() defer s.cacheMu.RUnlock() if s.httpStream == nil { return nil, fmt.Errorf("FileStream not initialized") } return s.httpStream.NewReader() } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // offsetWriter is a wrapper that skips a specified number of bytes before writing to the underlying writer type offsetWriter struct { writer io.Writer skipBytes int64 skipped int64 } func (ow *offsetWriter) Write(p []byte) (n int, err error) { if ow.skipped < ow.skipBytes { // We still need to skip some bytes remaining := ow.skipBytes - ow.skipped if int64(len(p)) <= remaining { // Skip all of this write ow.skipped += int64(len(p)) return len(p), nil } else { // Skip part of this write and write the rest skipCount := remaining ow.skipped = ow.skipBytes return ow.writer.Write(p[skipCount:]) } } // No more skipping needed, write everything return ow.writer.Write(p) } func fetchContentLength(ctx context.Context, url string) (int64, error) { req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) if err != nil { return 0, fmt.Errorf("failed to create HEAD request: %w", err) } resp, err := http.DefaultClient.Do(req) if err != nil { return 0, fmt.Errorf("failed to fetch content length: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return 0, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } contentLength := resp.ContentLength if contentLength < 0 { return 0, errors.New("content length not provided") } return contentLength, nil } type StreamInfo struct { ContentType string ContentLength int64 } func (s *DebridStream) FetchStreamInfo(streamUrl string) (info *StreamInfo, canStream bool) { hasExtension, isArchive := IsArchive(streamUrl) // If we were able to verify that the stream URL is an archive, we can't stream it if isArchive { s.logger.Warn().Str("url", streamUrl).Msg("directstream(debrid): Stream URL is an archive, cannot stream") return nil, false } // If the stream URL has an extension, we can stream it if hasExtension { ext := filepath.Ext(streamUrl) // If not a valid video extension, we can't stream it if !util.IsValidVideoExtension(ext) { s.logger.Warn().Str("url", streamUrl).Str("ext", ext).Msg("directstream(debrid): Stream URL has an invalid video extension, cannot stream") return nil, false } } // We'll fetch headers to get the info // If the headers are not available, we can't stream it contentType, contentLength, err := s.GetContentTypeAndLength(streamUrl) if err != nil { s.logger.Error().Err(err).Str("url", streamUrl).Msg("directstream(debrid): Failed to fetch content type and length") return nil, false } // If not a video content type, we can't stream it if !strings.HasPrefix(contentType, "video/") && contentType != "application/octet-stream" && contentType != "application/force-download" { s.logger.Warn().Str("url", streamUrl).Str("contentType", contentType).Msg("directstream(debrid): Stream URL has an invalid content type, cannot stream") return nil, false } return &StreamInfo{ ContentType: contentType, ContentLength: contentLength, }, true } func IsArchive(streamUrl string) (hasExtension bool, isArchive bool) { ext := filepath.Ext(streamUrl) if ext == ".zip" || ext == ".rar" { return true, true } if ext != "" { return true, false } return false, false } func GetContentTypeAndLengthHead(url string) (string, string) { resp, err := http.Head(url) if err != nil { return "", "" } defer resp.Body.Close() return resp.Header.Get("Content-Type"), resp.Header.Get("Content-Length") } func (s *DebridStream) GetContentTypeAndLength(url string) (string, int64, error) { // Try using HEAD request cType, cLength := GetContentTypeAndLengthHead(url) length, err := strconv.ParseInt(cLength, 10, 64) if err != nil && cLength != "" { s.logger.Error().Err(err).Str("contentType", cType).Str("contentLength", cLength).Msg("directstream(debrid): Failed to parse content length from header") return "", 0, fmt.Errorf("failed to parse content length: %w", err) } if cType != "" { return cType, length, nil } s.logger.Trace().Msg("directstream(debrid): Content type not found in headers, falling back to GET request") req, err := http.NewRequest("GET", url, nil) if err != nil { return "", 0, err } // Only read a small amount of data to determine the content type. req.Header.Set("Range", "bytes=0-511") resp, err := http.DefaultClient.Do(req) if err != nil { return "", 0, err } defer resp.Body.Close() // Read the first 512 bytes buf := make([]byte, 512) n, err := resp.Body.Read(buf) if err != nil && err != io.EOF { return "", 0, err } // Detect content type based on the read bytes contentType := http.DetectContentType(buf[:n]) return contentType, length, nil }