node build fixed
This commit is contained in:
723
seanime-2.9.10/internal/directstream/debridstream.go
Normal file
723
seanime-2.9.10/internal/directstream/debridstream.go
Normal file
@@ -0,0 +1,723 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user