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
|
||||
}
|
||||
319
seanime-2.9.10/internal/directstream/localfile.go
Normal file
319
seanime-2.9.10/internal/directstream/localfile.go
Normal file
@@ -0,0 +1,319 @@
|
||||
package directstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/library/anime"
|
||||
"seanime/internal/mkvparser"
|
||||
"seanime/internal/nativeplayer"
|
||||
"seanime/internal/util"
|
||||
"seanime/internal/util/result"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/samber/mo"
|
||||
)
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Local File
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
var _ Stream = (*LocalFileStream)(nil)
|
||||
|
||||
// LocalFileStream is a stream that is a local file.
|
||||
type LocalFileStream struct {
|
||||
BaseStream
|
||||
localFile *anime.LocalFile
|
||||
}
|
||||
|
||||
func (s *LocalFileStream) newReader() (io.ReadSeekCloser, error) {
|
||||
r, err := os.OpenFile(s.localFile.Path, os.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *LocalFileStream) Type() nativeplayer.StreamType {
|
||||
return nativeplayer.StreamTypeFile
|
||||
}
|
||||
|
||||
func (s *LocalFileStream) LoadContentType() string {
|
||||
s.contentTypeOnce.Do(func() {
|
||||
// No need to pass a reader because we are not going to read the file
|
||||
// Get the mime type from the file extension
|
||||
s.contentType = loadContentType(s.localFile.Path)
|
||||
})
|
||||
|
||||
return s.contentType
|
||||
}
|
||||
|
||||
func (s *LocalFileStream) LoadPlaybackInfo() (ret *nativeplayer.PlaybackInfo, err error) {
|
||||
s.playbackInfoOnce.Do(func() {
|
||||
if s.localFile == nil {
|
||||
s.playbackInfo = &nativeplayer.PlaybackInfo{}
|
||||
err = fmt.Errorf("local file is not set")
|
||||
s.playbackInfoErr = err
|
||||
return
|
||||
}
|
||||
|
||||
// Open the file
|
||||
fr, err := s.newReader()
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("directstream(file): Failed to open local file")
|
||||
s.manager.preStreamError(s, fmt.Errorf("cannot stream local file: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Close the file when done
|
||||
defer func() {
|
||||
if closer, ok := fr.(io.Closer); ok {
|
||||
s.logger.Trace().Msg("directstream(file): Closing local file reader")
|
||||
_ = closer.Close()
|
||||
} else {
|
||||
s.logger.Trace().Msg("directstream(file): Local file reader does not implement io.Closer")
|
||||
}
|
||||
}()
|
||||
|
||||
// Get the file size
|
||||
size, err := fr.Seek(0, io.SeekEnd)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("directstream(file): Failed to get file size")
|
||||
s.manager.preStreamError(s, fmt.Errorf("failed to get file size: %w", err))
|
||||
return
|
||||
}
|
||||
_, _ = fr.Seek(0, io.SeekStart)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
playbackInfo := nativeplayer.PlaybackInfo{
|
||||
ID: id,
|
||||
StreamType: s.Type(),
|
||||
MimeType: s.LoadContentType(),
|
||||
StreamUrl: "{{SERVER_URL}}/api/v1/directstream/stream?id=" + id,
|
||||
ContentLength: size,
|
||||
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()) {
|
||||
|
||||
parserKey := util.Base64EncodeStr(s.localFile.Path)
|
||||
|
||||
parser, ok := s.manager.parserCache.Get(parserKey)
|
||||
if !ok {
|
||||
parser = mkvparser.NewMetadataParser(fr, s.logger)
|
||||
s.manager.parserCache.SetT(parserKey, parser, 2*time.Hour)
|
||||
}
|
||||
|
||||
metadata := parser.GetMetadata(context.Background())
|
||||
if metadata.Error != nil {
|
||||
s.logger.Error().Err(metadata.Error).Msg("directstream(torrent): Failed to get metadata")
|
||||
s.manager.preStreamError(s, fmt.Errorf("failed to get metadata: %w", metadata.Error))
|
||||
s.playbackInfoErr = fmt.Errorf("failed to get metadata: %w", metadata.Error)
|
||||
return
|
||||
}
|
||||
|
||||
playbackInfo.MkvMetadata = metadata
|
||||
playbackInfo.MkvMetadataParser = mo.Some(parser)
|
||||
}
|
||||
|
||||
s.playbackInfo = &playbackInfo
|
||||
})
|
||||
|
||||
return s.playbackInfo, s.playbackInfoErr
|
||||
}
|
||||
|
||||
func (s *LocalFileStream) GetAttachmentByName(filename string) (*mkvparser.AttachmentInfo, bool) {
|
||||
return getAttachmentByName(s.manager.playbackCtx, s, filename)
|
||||
}
|
||||
|
||||
func (s *LocalFileStream) GetStreamHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s.logger.Trace().Str("method", r.Method).Msg("directstream: Received request")
|
||||
|
||||
defer func() {
|
||||
s.logger.Trace().Msg("directstream: Request finished")
|
||||
}()
|
||||
|
||||
if r.Method == http.MethodHead {
|
||||
// Get the file size
|
||||
fileInfo, err := os.Stat(s.localFile.Path)
|
||||
if err != nil {
|
||||
s.logger.Error().Msg("directstream: Failed to get file info")
|
||||
http.Error(w, "Failed to get file info", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Set the content length
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
|
||||
w.Header().Set("Content-Type", s.LoadContentType())
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", s.localFile.Path))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
ServeLocalFile(w, r, s)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func ServeLocalFile(w http.ResponseWriter, r *http.Request, lfStream *LocalFileStream) {
|
||||
playbackInfo, err := lfStream.LoadPlaybackInfo()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
size := playbackInfo.ContentLength
|
||||
|
||||
if isThumbnailRequest(r) {
|
||||
reader, err := lfStream.newReader()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ra, ok := handleRange(w, r, reader, lfStream.localFile.Path, size)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
serveContentRange(w, r, r.Context(), reader, lfStream.localFile.Path, size, playbackInfo.MimeType, ra)
|
||||
return
|
||||
}
|
||||
|
||||
if lfStream.serveContentCancelFunc != nil {
|
||||
lfStream.serveContentCancelFunc()
|
||||
}
|
||||
|
||||
ct, cancel := context.WithCancel(lfStream.manager.playbackCtx)
|
||||
lfStream.serveContentCancelFunc = cancel
|
||||
|
||||
reader, err := lfStream.newReader()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
ra, ok := handleRange(w, r, reader, lfStream.localFile.Path, size)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := playbackInfo.MkvMetadataParser.Get(); ok {
|
||||
// Start a subtitle stream from the current position
|
||||
subReader, err := lfStream.newReader()
|
||||
if err != nil {
|
||||
lfStream.logger.Error().Err(err).Msg("directstream: Failed to create subtitle reader")
|
||||
http.Error(w, "Failed to create subtitle reader", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
go lfStream.StartSubtitleStream(lfStream, lfStream.manager.playbackCtx, subReader, ra.Start)
|
||||
}
|
||||
|
||||
serveContentRange(w, r, ct, reader, lfStream.localFile.Path, size, playbackInfo.MimeType, ra)
|
||||
}
|
||||
|
||||
type PlayLocalFileOptions struct {
|
||||
ClientId string
|
||||
Path string
|
||||
LocalFiles []*anime.LocalFile
|
||||
}
|
||||
|
||||
// PlayLocalFile is used by a module to load a new torrent stream.
|
||||
func (m *Manager) PlayLocalFile(ctx context.Context, opts PlayLocalFileOptions) error {
|
||||
m.playbackMu.Lock()
|
||||
defer m.playbackMu.Unlock()
|
||||
|
||||
animeCollection, ok := m.animeCollection.Get()
|
||||
if !ok {
|
||||
return fmt.Errorf("cannot play local file, anime collection is not set")
|
||||
}
|
||||
|
||||
// Get the local file
|
||||
var lf *anime.LocalFile
|
||||
for _, l := range opts.LocalFiles {
|
||||
if util.NormalizePath(l.Path) == util.NormalizePath(opts.Path) {
|
||||
lf = l
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if lf == nil {
|
||||
return fmt.Errorf("cannot play local file, could not find local file: %s", opts.Path)
|
||||
}
|
||||
|
||||
if lf.MediaId == 0 {
|
||||
return fmt.Errorf("local file has not been matched to a media: %s", opts.Path)
|
||||
}
|
||||
|
||||
mId := lf.MediaId
|
||||
var media *anilist.BaseAnime
|
||||
listEntry, ok := animeCollection.GetListEntryFromAnimeId(mId)
|
||||
if ok {
|
||||
media = listEntry.Media
|
||||
}
|
||||
|
||||
if media == nil {
|
||||
return fmt.Errorf("media not found in anime collection: %d", mId)
|
||||
}
|
||||
|
||||
episodeCollection, err := anime.NewEpisodeCollectionFromLocalFiles(ctx, anime.NewEpisodeCollectionFromLocalFilesOptions{
|
||||
LocalFiles: opts.LocalFiles,
|
||||
Media: media,
|
||||
AnimeCollection: animeCollection,
|
||||
Platform: m.platform,
|
||||
MetadataProvider: m.metadataProvider,
|
||||
Logger: m.Logger,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot play local file, could not create episode collection: %w", err)
|
||||
}
|
||||
|
||||
var episode *anime.Episode
|
||||
for _, e := range episodeCollection.Episodes {
|
||||
if e.LocalFile != nil && util.NormalizePath(e.LocalFile.Path) == util.NormalizePath(lf.Path) {
|
||||
episode = e
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if episode == nil {
|
||||
return fmt.Errorf("cannot play local file, could not find episode for local file: %s", opts.Path)
|
||||
}
|
||||
|
||||
stream := &LocalFileStream{
|
||||
localFile: lf,
|
||||
BaseStream: BaseStream{
|
||||
manager: m,
|
||||
logger: m.Logger,
|
||||
clientId: opts.ClientId,
|
||||
filename: filepath.Base(lf.Path),
|
||||
media: media,
|
||||
episode: episode,
|
||||
episodeCollection: episodeCollection,
|
||||
subtitleEventCache: result.NewResultMap[string, *mkvparser.SubtitleEvent](),
|
||||
activeSubtitleStreams: result.NewResultMap[string, *SubtitleStream](),
|
||||
},
|
||||
}
|
||||
|
||||
m.loadStream(stream)
|
||||
|
||||
return nil
|
||||
}
|
||||
139
seanime-2.9.10/internal/directstream/manager.go
Normal file
139
seanime-2.9.10/internal/directstream/manager.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package directstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata"
|
||||
"seanime/internal/continuity"
|
||||
discordrpc_presence "seanime/internal/discordrpc/presence"
|
||||
"seanime/internal/events"
|
||||
"seanime/internal/library/anime"
|
||||
"seanime/internal/mkvparser"
|
||||
"seanime/internal/nativeplayer"
|
||||
"seanime/internal/platforms/platform"
|
||||
"seanime/internal/util/result"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/samber/mo"
|
||||
)
|
||||
|
||||
// Manager handles direct stream playback and progress tracking for the built-in video player.
|
||||
// It is similar to [playbackmanager.PlaybackManager].
|
||||
type (
|
||||
Manager struct {
|
||||
Logger *zerolog.Logger
|
||||
|
||||
// ------------ Modules ------------- //
|
||||
|
||||
wsEventManager events.WSEventManagerInterface
|
||||
continuityManager *continuity.Manager
|
||||
metadataProvider metadata.Provider
|
||||
discordPresence *discordrpc_presence.Presence
|
||||
platform platform.Platform
|
||||
refreshAnimeCollectionFunc func() // This function is called to refresh the AniList collection
|
||||
|
||||
nativePlayer *nativeplayer.NativePlayer
|
||||
nativePlayerSubscriber *nativeplayer.Subscriber
|
||||
|
||||
// --------- Playback Context -------- //
|
||||
|
||||
playbackMu sync.Mutex
|
||||
playbackCtx context.Context
|
||||
playbackCtxCancelFunc context.CancelFunc
|
||||
|
||||
// ---------- Playback State ---------- //
|
||||
|
||||
currentStream mo.Option[Stream] // The current stream being played
|
||||
|
||||
// \/ Stream playback
|
||||
// This is set by [SetStreamEpisodeCollection]
|
||||
currentStreamEpisodeCollection mo.Option[*anime.EpisodeCollection]
|
||||
|
||||
settings *Settings
|
||||
|
||||
isOffline *bool
|
||||
animeCollection mo.Option[*anilist.AnimeCollection]
|
||||
animeCache *result.Cache[int, *anilist.BaseAnime]
|
||||
|
||||
parserCache *result.Cache[string, *mkvparser.MetadataParser]
|
||||
//playbackStatusSubscribers *result.Map[string, *PlaybackStatusSubscriber]
|
||||
}
|
||||
|
||||
Settings struct {
|
||||
AutoPlayNextEpisode bool
|
||||
AutoUpdateProgress bool
|
||||
}
|
||||
|
||||
NewManagerOptions struct {
|
||||
Logger *zerolog.Logger
|
||||
WSEventManager events.WSEventManagerInterface
|
||||
MetadataProvider metadata.Provider
|
||||
ContinuityManager *continuity.Manager
|
||||
DiscordPresence *discordrpc_presence.Presence
|
||||
Platform platform.Platform
|
||||
RefreshAnimeCollectionFunc func()
|
||||
IsOffline *bool
|
||||
NativePlayer *nativeplayer.NativePlayer
|
||||
}
|
||||
)
|
||||
|
||||
func NewManager(options NewManagerOptions) *Manager {
|
||||
ret := &Manager{
|
||||
Logger: options.Logger,
|
||||
wsEventManager: options.WSEventManager,
|
||||
metadataProvider: options.MetadataProvider,
|
||||
continuityManager: options.ContinuityManager,
|
||||
discordPresence: options.DiscordPresence,
|
||||
platform: options.Platform,
|
||||
refreshAnimeCollectionFunc: options.RefreshAnimeCollectionFunc,
|
||||
isOffline: options.IsOffline,
|
||||
currentStream: mo.None[Stream](),
|
||||
nativePlayer: options.NativePlayer,
|
||||
parserCache: result.NewCache[string, *mkvparser.MetadataParser](),
|
||||
}
|
||||
|
||||
ret.nativePlayerSubscriber = ret.nativePlayer.Subscribe("directstream")
|
||||
|
||||
ret.listenToNativePlayerEvents()
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func (m *Manager) SetAnimeCollection(ac *anilist.AnimeCollection) {
|
||||
m.animeCollection = mo.Some(ac)
|
||||
}
|
||||
|
||||
func (m *Manager) SetSettings(s *Settings) {
|
||||
m.settings = s
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func (m *Manager) getAnime(ctx context.Context, mediaId int) (*anilist.BaseAnime, error) {
|
||||
media, ok := m.animeCache.Get(mediaId)
|
||||
if ok {
|
||||
return media, nil
|
||||
}
|
||||
|
||||
// Find in anime collection
|
||||
animeCollection, ok := m.animeCollection.Get()
|
||||
if ok {
|
||||
media, ok := animeCollection.FindAnime(mediaId)
|
||||
if ok {
|
||||
return media, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Find in platform
|
||||
media, err := m.platform.GetAnime(ctx, mediaId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache
|
||||
m.animeCache.SetT(mediaId, media, 1*time.Hour)
|
||||
|
||||
return media, nil
|
||||
}
|
||||
39
seanime-2.9.10/internal/directstream/serve.go
Normal file
39
seanime-2.9.10/internal/directstream/serve.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package directstream
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// ServeEchoStream is a proxy to the current stream.
|
||||
// It sits in between the player and the real stream (whether it's a local file, torrent, or http stream).
|
||||
//
|
||||
// If this is an EBML stream, it gets the range request from the player, processes it to stream the correct subtitles, and serves the video.
|
||||
// Otherwise, it just serves the video.
|
||||
func (m *Manager) ServeEchoStream() http.Handler {
|
||||
return m.getStreamHandler()
|
||||
}
|
||||
|
||||
// ServeEchoAttachments serves the attachments loaded into memory from the current stream.
|
||||
func (m *Manager) ServeEchoAttachments(c echo.Context) error {
|
||||
// Get the current stream
|
||||
stream, ok := m.currentStream.Get()
|
||||
if !ok {
|
||||
return errors.New("no stream")
|
||||
}
|
||||
|
||||
filename := c.Param("*")
|
||||
|
||||
filename, _ = url.PathUnescape(filename)
|
||||
|
||||
// Get the attachment
|
||||
attachment, ok := stream.GetAttachmentByName(filename)
|
||||
if !ok {
|
||||
return errors.New("attachment not found")
|
||||
}
|
||||
|
||||
return c.Blob(200, attachment.Mimetype, attachment.Data)
|
||||
}
|
||||
426
seanime-2.9.10/internal/directstream/stream.go
Normal file
426
seanime-2.9.10/internal/directstream/stream.go
Normal file
@@ -0,0 +1,426 @@
|
||||
package directstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/continuity"
|
||||
discordrpc_presence "seanime/internal/discordrpc/presence"
|
||||
"seanime/internal/library/anime"
|
||||
"seanime/internal/mkvparser"
|
||||
"seanime/internal/nativeplayer"
|
||||
"seanime/internal/util/result"
|
||||
"sync"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/samber/mo"
|
||||
)
|
||||
|
||||
// Stream is the common interface for all stream types.
|
||||
type Stream interface {
|
||||
// Type returns the type of the stream.
|
||||
Type() nativeplayer.StreamType
|
||||
// LoadContentType loads and returns the content type of the stream.
|
||||
// e.g. "video/mp4", "video/webm", "video/x-matroska"
|
||||
LoadContentType() string
|
||||
// ClientId returns the client ID of the current stream.
|
||||
ClientId() string
|
||||
// Media returns the media of the current stream.
|
||||
Media() *anilist.BaseAnime
|
||||
// Episode returns the episode of the current stream.
|
||||
Episode() *anime.Episode
|
||||
// ListEntryData returns the list entry data for the current stream.
|
||||
ListEntryData() *anime.EntryListData
|
||||
// EpisodeCollection returns the episode collection for the media of the current stream.
|
||||
EpisodeCollection() *anime.EpisodeCollection
|
||||
// LoadPlaybackInfo loads and returns the playback info.
|
||||
LoadPlaybackInfo() (*nativeplayer.PlaybackInfo, error)
|
||||
// GetAttachmentByName returns the attachment by name for the stream.
|
||||
// It is used to serve fonts and other attachments.
|
||||
GetAttachmentByName(filename string) (*mkvparser.AttachmentInfo, bool)
|
||||
// GetStreamHandler returns the stream handler.
|
||||
GetStreamHandler() http.Handler
|
||||
// StreamError is called when an error occurs while streaming.
|
||||
// This is used to notify the native player that an error occurred.
|
||||
// It will close the stream.
|
||||
StreamError(err error)
|
||||
// Terminate ends the stream.
|
||||
// Once this is called, the stream should not be used anymore.
|
||||
Terminate()
|
||||
// GetSubtitleEventCache accesses the subtitle event cache.
|
||||
GetSubtitleEventCache() *result.Map[string, *mkvparser.SubtitleEvent]
|
||||
// OnSubtitleFileUploaded is called when a subtitle file is uploaded.
|
||||
OnSubtitleFileUploaded(filename string, content string)
|
||||
}
|
||||
|
||||
func (m *Manager) getStreamHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
stream, ok := m.currentStream.Get()
|
||||
if !ok {
|
||||
http.Error(w, "no stream", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
stream.GetStreamHandler().ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) PrepareNewStream(clientId string, step string) {
|
||||
m.prepareNewStream(clientId, step)
|
||||
}
|
||||
|
||||
func (m *Manager) prepareNewStream(clientId string, step string) {
|
||||
// Cancel the previous playback
|
||||
if m.playbackCtxCancelFunc != nil {
|
||||
m.Logger.Trace().Msgf("directstream: Cancelling previous playback")
|
||||
m.playbackCtxCancelFunc()
|
||||
m.playbackCtxCancelFunc = nil
|
||||
}
|
||||
|
||||
// Clear the current stream if it exists
|
||||
if stream, ok := m.currentStream.Get(); ok {
|
||||
m.Logger.Debug().Msgf("directstream: Terminating previous stream before preparing new stream")
|
||||
stream.Terminate()
|
||||
m.currentStream = mo.None[Stream]()
|
||||
}
|
||||
|
||||
m.Logger.Debug().Msgf("directstream: Signaling native player that a new stream is starting")
|
||||
// Signal the native player that a new stream is starting
|
||||
m.nativePlayer.OpenAndAwait(clientId, step)
|
||||
}
|
||||
|
||||
// loadStream loads a new stream and cancels the previous one.
|
||||
// Caller should use mutex to lock the manager.
|
||||
func (m *Manager) loadStream(stream Stream) {
|
||||
m.prepareNewStream(stream.ClientId(), "Loading stream...")
|
||||
|
||||
m.Logger.Debug().Msgf("directstream: Loading stream")
|
||||
m.currentStream = mo.Some(stream)
|
||||
|
||||
// Create a new context
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
m.playbackCtx = ctx
|
||||
m.playbackCtxCancelFunc = cancel
|
||||
|
||||
m.Logger.Debug().Msgf("directstream: Loading content type")
|
||||
m.nativePlayer.OpenAndAwait(stream.ClientId(), "Loading metadata...")
|
||||
// Load the content type
|
||||
contentType := stream.LoadContentType()
|
||||
if contentType == "" {
|
||||
m.Logger.Error().Msg("directstream: Failed to load content type")
|
||||
m.preStreamError(stream, fmt.Errorf("failed to load content type"))
|
||||
return
|
||||
}
|
||||
|
||||
m.Logger.Debug().Msgf("directstream: Signaling native player that metadata is being loaded")
|
||||
|
||||
// Load the playback info
|
||||
// If EBML, it will block until the metadata is parsed
|
||||
playbackInfo, err := stream.LoadPlaybackInfo()
|
||||
if err != nil {
|
||||
m.Logger.Error().Err(err).Msg("directstream: Failed to load playback info")
|
||||
m.preStreamError(stream, fmt.Errorf("failed to load playback info: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Shut the mkv parser logger
|
||||
//parser, ok := playbackInfo.MkvMetadataParser.Get()
|
||||
//if ok {
|
||||
// parser.SetLoggerEnabled(false)
|
||||
//}
|
||||
|
||||
m.Logger.Debug().Msgf("directstream: Signaling native player that stream is ready")
|
||||
m.nativePlayer.Watch(stream.ClientId(), playbackInfo)
|
||||
}
|
||||
|
||||
func (m *Manager) listenToNativePlayerEvents() {
|
||||
go func() {
|
||||
defer func() {
|
||||
m.Logger.Trace().Msg("directstream: Stream loop goroutine exited")
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case event := <-m.nativePlayerSubscriber.Events():
|
||||
cs, ok := m.currentStream.Get()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if event.GetClientId() != "" && event.GetClientId() != cs.ClientId() {
|
||||
continue
|
||||
}
|
||||
switch event := event.(type) {
|
||||
case *nativeplayer.VideoPausedEvent:
|
||||
m.Logger.Debug().Msgf("directstream: Video paused")
|
||||
|
||||
// Discord
|
||||
if m.discordPresence != nil && !*m.isOffline {
|
||||
go m.discordPresence.UpdateAnimeActivity(int(event.CurrentTime), int(event.Duration), true)
|
||||
}
|
||||
case *nativeplayer.VideoResumedEvent:
|
||||
m.Logger.Debug().Msgf("directstream: Video resumed")
|
||||
|
||||
// Discord
|
||||
if m.discordPresence != nil && !*m.isOffline {
|
||||
go m.discordPresence.UpdateAnimeActivity(int(event.CurrentTime), int(event.Duration), false)
|
||||
}
|
||||
case *nativeplayer.VideoEndedEvent:
|
||||
m.Logger.Debug().Msgf("directstream: Video ended")
|
||||
|
||||
// Discord
|
||||
if m.discordPresence != nil && !*m.isOffline {
|
||||
go m.discordPresence.Close()
|
||||
}
|
||||
case *nativeplayer.VideoSeekedEvent:
|
||||
m.Logger.Debug().Msgf("directstream: Video seeked, CurrentTime: %f", event.CurrentTime)
|
||||
// Convert video timestamp to byte offset for subtitle extraction
|
||||
// if event.CurrentTime > 0 {
|
||||
// cs.ServeSubtitlesFromTime(event.CurrentTime)
|
||||
// }
|
||||
case *nativeplayer.VideoLoadedMetadataEvent:
|
||||
m.Logger.Debug().Msgf("directstream: Video loaded metadata")
|
||||
// Start subtitle extraction from the beginning
|
||||
// cs.ServeSubtitlesFromTime(0.0)
|
||||
if lfStream, ok := cs.(*LocalFileStream); ok {
|
||||
subReader, err := lfStream.newReader()
|
||||
if err != nil {
|
||||
m.Logger.Error().Err(err).Msg("directstream: Failed to create subtitle reader")
|
||||
cs.StreamError(fmt.Errorf("failed to create subtitle reader: %w", err))
|
||||
return
|
||||
}
|
||||
lfStream.StartSubtitleStream(lfStream, m.playbackCtx, subReader, 0)
|
||||
} else if ts, ok := cs.(*TorrentStream); ok {
|
||||
subReader := ts.file.NewReader()
|
||||
subReader.SetResponsive()
|
||||
ts.StartSubtitleStream(ts, m.playbackCtx, subReader, 0)
|
||||
}
|
||||
|
||||
// Discord
|
||||
if m.discordPresence != nil && !*m.isOffline {
|
||||
go m.discordPresence.SetAnimeActivity(&discordrpc_presence.AnimeActivity{
|
||||
ID: cs.Media().GetID(),
|
||||
Title: cs.Media().GetPreferredTitle(),
|
||||
Image: cs.Media().GetCoverImageSafe(),
|
||||
IsMovie: cs.Media().IsMovie(),
|
||||
EpisodeNumber: cs.Episode().ProgressNumber,
|
||||
Progress: int(event.CurrentTime),
|
||||
Duration: int(event.Duration),
|
||||
})
|
||||
}
|
||||
case *nativeplayer.VideoErrorEvent:
|
||||
m.Logger.Debug().Msgf("directstream: Video error, Error: %s", event.Error)
|
||||
cs.StreamError(fmt.Errorf(event.Error))
|
||||
|
||||
// Discord
|
||||
if m.discordPresence != nil && !*m.isOffline {
|
||||
go m.discordPresence.Close()
|
||||
}
|
||||
case *nativeplayer.SubtitleFileUploadedEvent:
|
||||
m.Logger.Debug().Msgf("directstream: Subtitle file uploaded, Filename: %s", event.Filename)
|
||||
cs.OnSubtitleFileUploaded(event.Filename, event.Content)
|
||||
case *nativeplayer.VideoTerminatedEvent:
|
||||
m.Logger.Debug().Msgf("directstream: Video terminated")
|
||||
cs.Terminate()
|
||||
|
||||
// Discord
|
||||
if m.discordPresence != nil && !*m.isOffline {
|
||||
go m.discordPresence.Close()
|
||||
}
|
||||
case *nativeplayer.VideoStatusEvent:
|
||||
_ = m.continuityManager.UpdateWatchHistoryItem(&continuity.UpdateWatchHistoryItemOptions{
|
||||
CurrentTime: event.Status.CurrentTime,
|
||||
Duration: event.Status.Duration,
|
||||
MediaId: cs.Media().GetID(),
|
||||
EpisodeNumber: cs.Episode().GetEpisodeNumber(),
|
||||
Kind: continuity.MediastreamKind,
|
||||
})
|
||||
|
||||
// Discord
|
||||
if m.discordPresence != nil && !*m.isOffline {
|
||||
go m.discordPresence.UpdateAnimeActivity(int(event.Status.CurrentTime), int(event.Status.Duration), event.Status.Paused)
|
||||
}
|
||||
case *nativeplayer.VideoCompletedEvent:
|
||||
m.Logger.Debug().Msgf("directstream: Video completed")
|
||||
|
||||
if baseStream, ok := cs.(*BaseStream); ok {
|
||||
baseStream.updateProgress.Do(func() {
|
||||
mediaId := baseStream.media.GetID()
|
||||
epNum := baseStream.episode.GetProgressNumber()
|
||||
totalEpisodes := baseStream.media.GetTotalEpisodeCount() // total episode count or -1
|
||||
|
||||
_ = baseStream.manager.platform.UpdateEntryProgress(context.Background(), mediaId, epNum, &totalEpisodes)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (m *Manager) unloadStream() {
|
||||
m.playbackMu.Lock()
|
||||
defer m.playbackMu.Unlock()
|
||||
|
||||
m.Logger.Debug().Msg("directstream: Unloading current stream")
|
||||
|
||||
// Cancel any existing playback context first
|
||||
if m.playbackCtxCancelFunc != nil {
|
||||
m.Logger.Trace().Msg("directstream: Cancelling playback context")
|
||||
m.playbackCtxCancelFunc()
|
||||
m.playbackCtxCancelFunc = nil
|
||||
}
|
||||
|
||||
// Clear the current stream
|
||||
if stream, ok := m.currentStream.Get(); ok {
|
||||
m.Logger.Debug().Msg("directstream: Terminating current stream")
|
||||
stream.Terminate()
|
||||
}
|
||||
|
||||
m.currentStream = mo.None[Stream]()
|
||||
m.Logger.Debug().Msg("directstream: Stream unloaded successfully")
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type BaseStream struct {
|
||||
logger *zerolog.Logger
|
||||
clientId string
|
||||
contentType string
|
||||
contentTypeOnce sync.Once
|
||||
episode *anime.Episode
|
||||
media *anilist.BaseAnime
|
||||
listEntryData *anime.EntryListData
|
||||
episodeCollection *anime.EpisodeCollection
|
||||
playbackInfo *nativeplayer.PlaybackInfo
|
||||
playbackInfoErr error
|
||||
playbackInfoOnce sync.Once
|
||||
subtitleEventCache *result.Map[string, *mkvparser.SubtitleEvent]
|
||||
terminateOnce sync.Once
|
||||
serveContentCancelFunc context.CancelFunc
|
||||
filename string // Name of the file being streamed, if applicable
|
||||
|
||||
// Subtitle stream management
|
||||
activeSubtitleStreams *result.Map[string, *SubtitleStream]
|
||||
|
||||
manager *Manager
|
||||
updateProgress sync.Once
|
||||
}
|
||||
|
||||
var _ Stream = (*BaseStream)(nil)
|
||||
|
||||
func (s *BaseStream) GetAttachmentByName(filename string) (*mkvparser.AttachmentInfo, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (s *BaseStream) GetStreamHandler() http.Handler {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BaseStream) LoadContentType() string {
|
||||
return s.contentType
|
||||
}
|
||||
|
||||
func (s *BaseStream) LoadPlaybackInfo() (*nativeplayer.PlaybackInfo, error) {
|
||||
return s.playbackInfo, s.playbackInfoErr
|
||||
}
|
||||
|
||||
func (s *BaseStream) Type() nativeplayer.StreamType {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *BaseStream) Media() *anilist.BaseAnime {
|
||||
return s.media
|
||||
}
|
||||
|
||||
func (s *BaseStream) Episode() *anime.Episode {
|
||||
return s.episode
|
||||
}
|
||||
|
||||
func (s *BaseStream) ListEntryData() *anime.EntryListData {
|
||||
return s.listEntryData
|
||||
}
|
||||
|
||||
func (s *BaseStream) EpisodeCollection() *anime.EpisodeCollection {
|
||||
return s.episodeCollection
|
||||
}
|
||||
|
||||
func (s *BaseStream) ClientId() string {
|
||||
return s.clientId
|
||||
}
|
||||
|
||||
func (s *BaseStream) Terminate() {
|
||||
s.terminateOnce.Do(func() {
|
||||
// Cancel the playback context
|
||||
// This will snowball and cancel other stuff
|
||||
if s.manager.playbackCtxCancelFunc != nil {
|
||||
s.manager.playbackCtxCancelFunc()
|
||||
}
|
||||
|
||||
// Cancel all active subtitle streams
|
||||
s.activeSubtitleStreams.Range(func(_ string, s *SubtitleStream) bool {
|
||||
s.cleanupFunc()
|
||||
return true
|
||||
})
|
||||
s.activeSubtitleStreams.Clear()
|
||||
|
||||
s.subtitleEventCache.Clear()
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BaseStream) StreamError(err error) {
|
||||
s.logger.Error().Err(err).Msg("directstream: Stream error occurred")
|
||||
s.manager.nativePlayer.Error(s.clientId, err)
|
||||
s.Terminate()
|
||||
s.manager.unloadStream()
|
||||
}
|
||||
|
||||
func (s *BaseStream) GetSubtitleEventCache() *result.Map[string, *mkvparser.SubtitleEvent] {
|
||||
return s.subtitleEventCache
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Helpers
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// loadContentType loads the content type of the file.
|
||||
// If the content type cannot be determined from the file extension,
|
||||
// the first reader will be used to determine the content type.
|
||||
func loadContentType(path string, reader ...io.ReadSeekCloser) string {
|
||||
ext := filepath.Ext(path)
|
||||
|
||||
switch ext {
|
||||
case ".mp4":
|
||||
return "video/mp4"
|
||||
case ".mkv":
|
||||
//return "video/x-matroska"
|
||||
return "video/webm"
|
||||
case ".webm", ".m4v":
|
||||
return "video/webm"
|
||||
case ".avi":
|
||||
return "video/x-msvideo"
|
||||
case ".mov":
|
||||
return "video/quicktime"
|
||||
case ".flv":
|
||||
return "video/x-flv"
|
||||
default:
|
||||
}
|
||||
|
||||
// No extension found
|
||||
// Read the first 1KB to determine the content type
|
||||
if len(reader) > 0 {
|
||||
if mimeType, ok := mkvparser.ReadIsMkvOrWebm(reader[0]); ok {
|
||||
return mimeType
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *Manager) preStreamError(stream Stream, err error) {
|
||||
stream.Terminate()
|
||||
m.nativePlayer.Error(stream.ClientId(), err)
|
||||
m.unloadStream()
|
||||
}
|
||||
233
seanime-2.9.10/internal/directstream/stream_helpers.go
Normal file
233
seanime-2.9.10/internal/directstream/stream_helpers.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package directstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
httputil "seanime/internal/util/http"
|
||||
"time"
|
||||
|
||||
"github.com/neilotoole/streamcache"
|
||||
)
|
||||
|
||||
func handleRange(w http.ResponseWriter, r *http.Request, reader io.ReadSeekCloser, name string, size int64) (httputil.Range, bool) {
|
||||
// No Range header → let Go handle it
|
||||
rangeHdr := r.Header.Get("Range")
|
||||
if rangeHdr == "" {
|
||||
http.ServeContent(w, r, name, time.Now(), reader)
|
||||
return httputil.Range{}, false
|
||||
}
|
||||
|
||||
// Parse the range header
|
||||
ranges, err := httputil.ParseRange(rangeHdr, size)
|
||||
if err != nil && !errors.Is(err, httputil.ErrNoOverlap) {
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", size))
|
||||
http.Error(w, "Invalid Range", http.StatusRequestedRangeNotSatisfiable)
|
||||
return httputil.Range{}, false
|
||||
} else if err != nil && errors.Is(err, httputil.ErrNoOverlap) {
|
||||
// Let Go handle overlap
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", size))
|
||||
http.ServeContent(w, r, name, time.Now(), reader)
|
||||
return httputil.Range{}, false
|
||||
}
|
||||
|
||||
return ranges[0], true
|
||||
}
|
||||
|
||||
func serveContentRange(w http.ResponseWriter, r *http.Request, ctx context.Context, reader io.ReadSeekCloser, name string, size int64, contentType string, ra httputil.Range) {
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
// Validate range
|
||||
if ra.Start >= size || ra.Start < 0 || ra.Length <= 0 {
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", size))
|
||||
http.Error(w, "Range Not Satisfiable", http.StatusRequestedRangeNotSatisfiable)
|
||||
return
|
||||
}
|
||||
|
||||
// Set response headers for partial content
|
||||
w.Header().Set("Content-Range", ra.ContentRange(size))
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", ra.Length))
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
|
||||
// Seek to the requested position
|
||||
_, err := reader.Seek(ra.Start, io.SeekStart)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = copyWithContext(ctx, w, reader, ra.Length)
|
||||
}
|
||||
|
||||
func serveTorrent(w http.ResponseWriter, r *http.Request, ctx context.Context, reader io.ReadSeekCloser, name string, size int64, contentType string, ra httputil.Range) {
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
// Validate range
|
||||
if ra.Start >= size || ra.Start < 0 || ra.Length <= 0 {
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", size))
|
||||
http.Error(w, "Range Not Satisfiable", http.StatusRequestedRangeNotSatisfiable)
|
||||
return
|
||||
}
|
||||
|
||||
// Set response headers for partial content
|
||||
w.Header().Set("Content-Range", ra.ContentRange(size))
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", ra.Length))
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
|
||||
// Seek to the requested position
|
||||
_, err := reader.Seek(ra.Start, io.SeekStart)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = copyWithContext(ctx, w, reader, ra.Length)
|
||||
}
|
||||
|
||||
// copyWithContext copies n bytes from src to dst, respecting context cancellation
|
||||
func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader, n int64) (int64, error) {
|
||||
// Use a reasonably sized buffer
|
||||
buf := make([]byte, 32*1024) // 32KB buffer
|
||||
|
||||
var flusher http.Flusher
|
||||
if f, ok := dst.(http.Flusher); ok {
|
||||
flusher = f
|
||||
}
|
||||
|
||||
var written int64
|
||||
for written < n {
|
||||
// Check if context is done before each read
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return written, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
// Calculate how much to read this iteration
|
||||
toRead := int64(len(buf))
|
||||
if n-written < toRead {
|
||||
toRead = n - written
|
||||
}
|
||||
|
||||
// Read from source
|
||||
nr, readErr := io.LimitReader(src, toRead).Read(buf)
|
||||
if nr > 0 {
|
||||
// Write to destination
|
||||
nw, writeErr := dst.Write(buf[:nr])
|
||||
if nw < nr {
|
||||
return written + int64(nw), writeErr
|
||||
}
|
||||
written += int64(nr)
|
||||
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// Handle write error
|
||||
if writeErr != nil {
|
||||
return written, writeErr
|
||||
}
|
||||
}
|
||||
|
||||
// Handle read error or EOF
|
||||
if readErr != nil {
|
||||
if readErr == io.EOF {
|
||||
if written >= n {
|
||||
return written, nil // Successfully read everything requested
|
||||
}
|
||||
}
|
||||
return written, readErr
|
||||
}
|
||||
}
|
||||
|
||||
return written, nil
|
||||
}
|
||||
|
||||
func isThumbnailRequest(r *http.Request) bool {
|
||||
return r.URL.Query().Get("thumbnail") == "true"
|
||||
}
|
||||
|
||||
func copyWithFlush(ctx context.Context, w http.ResponseWriter, rdr io.Reader, totalBytes int64) {
|
||||
const flushThreshold = 1 * 1024 * 1024 // 1MiB
|
||||
buf := make([]byte, 32*1024) // 32KiB buffer
|
||||
var written int64
|
||||
var sinceLastFlush int64
|
||||
flusher, _ := w.(http.Flusher)
|
||||
|
||||
for written < totalBytes {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// B) Decide how many bytes to read this iteration
|
||||
toRead := int64(len(buf))
|
||||
if totalBytes-written < toRead {
|
||||
toRead = totalBytes - written
|
||||
}
|
||||
|
||||
lr := io.LimitReader(rdr, toRead)
|
||||
nr, readErr := lr.Read(buf)
|
||||
if nr > 0 {
|
||||
nw, writeErr := w.Write(buf[:nr])
|
||||
written += int64(nw)
|
||||
sinceLastFlush += int64(nw)
|
||||
|
||||
if flusher != nil && sinceLastFlush >= flushThreshold {
|
||||
flusher.Flush()
|
||||
sinceLastFlush = 0
|
||||
}
|
||||
if writeErr != nil {
|
||||
return
|
||||
}
|
||||
if nw < nr {
|
||||
// Client closed or truncated write
|
||||
return
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
// EOF or any other read error → stop streaming
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type StreamCacheReadSeekCloser struct {
|
||||
stream *streamcache.Stream
|
||||
streamReader *streamcache.Reader
|
||||
originalReader io.ReadSeekCloser
|
||||
}
|
||||
|
||||
var _ io.ReadSeekCloser = (*StreamCacheReadSeekCloser)(nil)
|
||||
|
||||
func NewStreamCacheReadSeekCloser(ctx context.Context, reader io.ReadSeekCloser) StreamCacheReadSeekCloser {
|
||||
stream := streamcache.New(reader)
|
||||
return StreamCacheReadSeekCloser{
|
||||
stream: stream,
|
||||
streamReader: stream.NewReader(ctx),
|
||||
originalReader: reader,
|
||||
}
|
||||
}
|
||||
|
||||
func (s StreamCacheReadSeekCloser) Read(p []byte) (n int, err error) {
|
||||
return s.streamReader.Read(p)
|
||||
}
|
||||
|
||||
func (s StreamCacheReadSeekCloser) Seek(offset int64, whence int) (int64, error) {
|
||||
return s.originalReader.Seek(offset, whence)
|
||||
}
|
||||
|
||||
func (s StreamCacheReadSeekCloser) Close() error {
|
||||
return s.originalReader.Close()
|
||||
}
|
||||
522
seanime-2.9.10/internal/directstream/subtitles.go
Normal file
522
seanime-2.9.10/internal/directstream/subtitles.go
Normal file
@@ -0,0 +1,522 @@
|
||||
package directstream
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"seanime/internal/events"
|
||||
"seanime/internal/mkvparser"
|
||||
"seanime/internal/util"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type SubtitleStream struct {
|
||||
stream Stream
|
||||
logger *zerolog.Logger
|
||||
parser *mkvparser.MetadataParser
|
||||
reader io.ReadSeekCloser
|
||||
offset int64
|
||||
completed bool // ran until the EOF
|
||||
|
||||
cleanupFunc func()
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
func (s *SubtitleStream) Stop(completed bool) {
|
||||
s.stopOnce.Do(func() {
|
||||
s.logger.Debug().Int64("offset", s.offset).Msg("directstream: Stopping subtitle stream")
|
||||
s.completed = completed
|
||||
s.cleanupFunc()
|
||||
})
|
||||
}
|
||||
|
||||
// StartSubtitleStreamP starts a subtitle stream for the given stream at the given offset with a specified backoff bytes.
|
||||
func (s *BaseStream) StartSubtitleStreamP(stream Stream, playbackCtx context.Context, newReader io.ReadSeekCloser, offset int64, backoffBytes int64) {
|
||||
mkvMetadataParser, ok := s.playbackInfo.MkvMetadataParser.Get()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Trace().Int64("offset", offset).Msg("directstream: Starting new subtitle stream")
|
||||
subtitleStream := &SubtitleStream{
|
||||
stream: stream,
|
||||
logger: s.logger,
|
||||
parser: mkvMetadataParser,
|
||||
reader: newReader,
|
||||
offset: offset,
|
||||
}
|
||||
|
||||
// Check if we have a completed subtitle stream for this offset
|
||||
shouldContinue := true
|
||||
s.activeSubtitleStreams.Range(func(key string, value *SubtitleStream) bool {
|
||||
// If a stream is completed and its offset comes before this one, we don't need to start a new stream
|
||||
// |------------------------------->| other stream
|
||||
// | this stream
|
||||
// ^^^ starting in an area the other stream has already completed
|
||||
if offset > 0 && value.offset <= offset && value.completed {
|
||||
shouldContinue = false
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if !shouldContinue {
|
||||
s.logger.Debug().Int64("offset", offset).Msg("directstream: Skipping subtitle stream, range already fulfilled")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, subtitleCtxCancel := context.WithCancel(playbackCtx)
|
||||
subtitleStream.cleanupFunc = subtitleCtxCancel
|
||||
|
||||
subtitleStreamId := uuid.New().String()
|
||||
s.activeSubtitleStreams.Set(subtitleStreamId, subtitleStream)
|
||||
|
||||
subtitleCh, errCh, _ := subtitleStream.parser.ExtractSubtitles(ctx, newReader, offset, backoffBytes)
|
||||
|
||||
firstEventSentCh := make(chan struct{})
|
||||
closeFirstEventSentOnce := sync.Once{}
|
||||
|
||||
onFirstEventSent := func() {
|
||||
closeFirstEventSentOnce.Do(func() {
|
||||
s.logger.Debug().Int64("offset", offset).Msg("directstream: First subtitle event sent")
|
||||
close(firstEventSentCh) // Notify that the first subtitle event has been sent
|
||||
})
|
||||
}
|
||||
|
||||
var lastSubtitleEvent *mkvparser.SubtitleEvent
|
||||
lastSubtitleEventRWMutex := sync.RWMutex{}
|
||||
|
||||
// Check every second if we need to end this stream
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
subtitleStream.Stop(false)
|
||||
return
|
||||
case <-ticker.C:
|
||||
if lastSubtitleEvent == nil {
|
||||
continue
|
||||
}
|
||||
shouldEnd := false
|
||||
lastSubtitleEventRWMutex.RLock()
|
||||
s.activeSubtitleStreams.Range(func(key string, value *SubtitleStream) bool {
|
||||
if key != subtitleStreamId {
|
||||
// If the other stream is ahead of this stream
|
||||
// and the last subtitle event is after the other stream's offset
|
||||
// |---------------> this stream
|
||||
// |-------------> other stream
|
||||
// ^^^ stop this stream where it reached the tail of the other stream
|
||||
if offset > 0 && offset < value.offset && lastSubtitleEvent.HeadPos >= value.offset {
|
||||
shouldEnd = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
lastSubtitleEventRWMutex.RUnlock()
|
||||
if shouldEnd {
|
||||
subtitleStream.Stop(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer func(reader io.ReadSeekCloser) {
|
||||
_ = reader.Close()
|
||||
s.logger.Trace().Int64("offset", offset).Msg("directstream: Closing subtitle stream goroutine")
|
||||
}(newReader)
|
||||
defer func() {
|
||||
onFirstEventSent()
|
||||
subtitleStream.cleanupFunc()
|
||||
}()
|
||||
|
||||
// Keep track if channels are active to manage loop termination
|
||||
subtitleChannelActive := true
|
||||
errorChannelActive := true
|
||||
|
||||
for subtitleChannelActive || errorChannelActive { // Loop as long as at least one channel might still produce data or a final status
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.logger.Debug().Int64("offset", offset).Msg("directstream: Subtitle streaming cancelled by context")
|
||||
return
|
||||
|
||||
case subtitle, ok := <-subtitleCh:
|
||||
if !ok {
|
||||
subtitleCh = nil // Mark as exhausted
|
||||
subtitleChannelActive = false
|
||||
if !errorChannelActive { // If both channels are exhausted, exit
|
||||
return
|
||||
}
|
||||
continue // Continue to wait for errorChannel or ctx.Done()
|
||||
}
|
||||
if subtitle != nil {
|
||||
onFirstEventSent()
|
||||
s.manager.nativePlayer.SubtitleEvent(stream.ClientId(), subtitle)
|
||||
lastSubtitleEventRWMutex.Lock()
|
||||
lastSubtitleEvent = subtitle
|
||||
lastSubtitleEventRWMutex.Unlock()
|
||||
}
|
||||
|
||||
case err, ok := <-errCh:
|
||||
if !ok {
|
||||
errCh = nil // Mark as exhausted
|
||||
errorChannelActive = false
|
||||
if !subtitleChannelActive { // If both channels are exhausted, exit
|
||||
return
|
||||
}
|
||||
continue // Continue to wait for subtitleChannel or ctx.Done()
|
||||
}
|
||||
// A value (error or nil) was received from errCh.
|
||||
// This is the terminal signal from the mkvparser's subtitle streaming process.
|
||||
if err != nil {
|
||||
s.logger.Warn().Err(err).Int64("offset", offset).Msg("directstream: Error streaming subtitles")
|
||||
} else {
|
||||
s.logger.Info().Int64("offset", offset).Msg("directstream: Subtitle streaming completed by parser.")
|
||||
subtitleStream.Stop(true)
|
||||
}
|
||||
return // Terminate goroutine
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
//// Then wait for first subtitle event or timeout to prevent indefinite stalling
|
||||
//if offset > 0 {
|
||||
// // Wait for cluster to be found first
|
||||
// <-startedCh
|
||||
//
|
||||
// select {
|
||||
// case <-firstEventSentCh:
|
||||
// s.logger.Debug().Int64("offset", offset).Msg("directstream: First subtitle event received, continuing")
|
||||
// case <-time.After(3 * time.Second):
|
||||
// s.logger.Debug().Int64("offset", offset).Msg("directstream: Subtitle timeout reached (3s), continuing without waiting")
|
||||
// case <-ctx.Done():
|
||||
// s.logger.Debug().Int64("offset", offset).Msg("directstream: Context cancelled while waiting for first subtitle")
|
||||
// return
|
||||
// }
|
||||
//}
|
||||
}
|
||||
|
||||
// StartSubtitleStream starts a subtitle stream for the given stream at the given offset.
|
||||
//
|
||||
// If the media has no MKV metadata, this function will do nothing.
|
||||
func (s *BaseStream) StartSubtitleStream(stream Stream, playbackCtx context.Context, newReader io.ReadSeekCloser, offset int64) {
|
||||
// use 1MB as the cluster padding for subtitle streams
|
||||
s.StartSubtitleStreamP(stream, playbackCtx, newReader, offset, 1024*1024)
|
||||
}
|
||||
|
||||
//// StartSubtitleStream is similar to BaseStream.StartSubtitleStream, but rate limits the requests to the external debrid server.
|
||||
//// - There will only be one subtitle stream at a time.
|
||||
//func (s *DebridStream) StartSubtitleStream(stream Stream, playbackCtx context.Context, newReader io.ReadSeekCloser, offset int64, end int64) {
|
||||
// mkvMetadataParser, ok := s.playbackInfo.MkvMetadataParser.Get()
|
||||
// if !ok {
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// s.logger.Trace().Int64("offset", offset).Msg("directstream(debrid): Starting new subtitle stream")
|
||||
// subtitleStream := &SubtitleStream{
|
||||
// stream: stream,
|
||||
// logger: s.logger,
|
||||
// parser: mkvMetadataParser,
|
||||
// reader: newReader,
|
||||
// offset: offset,
|
||||
// }
|
||||
//
|
||||
// s.activeSubtitleStreams.Range(func(key string, value *SubtitleStream) bool {
|
||||
// value.Stop(true)
|
||||
// return true
|
||||
// })
|
||||
//
|
||||
// ctx, subtitleCtxCancel := context.WithCancel(playbackCtx)
|
||||
// subtitleStream.cleanupFunc = subtitleCtxCancel
|
||||
//
|
||||
// subtitleStreamId := uuid.New().String()
|
||||
// s.activeSubtitleStreams.Set(subtitleStreamId, subtitleStream)
|
||||
//
|
||||
// subtitleCh, errCh, _ := subtitleStream.parser.ExtractSubtitles(ctx, newReader, offset)
|
||||
//
|
||||
// firstEventSentCh := make(chan struct{})
|
||||
// closeFirstEventSentOnce := sync.Once{}
|
||||
//
|
||||
// onFirstEventSent := func() {
|
||||
// closeFirstEventSentOnce.Do(func() {
|
||||
// s.logger.Debug().Int64("offset", offset).Msg("directstream: First subtitle event sent")
|
||||
// close(firstEventSentCh) // Notify that the first subtitle event has been sent
|
||||
// })
|
||||
// }
|
||||
//
|
||||
// var lastSubtitleEvent *mkvparser.SubtitleEvent
|
||||
// lastSubtitleEventRWMutex := sync.RWMutex{}
|
||||
//
|
||||
// // Check every second if we need to end this stream
|
||||
// go func() {
|
||||
// ticker := time.NewTicker(1 * time.Second)
|
||||
// defer ticker.Stop()
|
||||
// for {
|
||||
// select {
|
||||
// case <-ctx.Done():
|
||||
// subtitleStream.Stop(false)
|
||||
// return
|
||||
// case <-ticker.C:
|
||||
// if lastSubtitleEvent == nil {
|
||||
// continue
|
||||
// }
|
||||
// shouldEnd := false
|
||||
// lastSubtitleEventRWMutex.RLock()
|
||||
// s.activeSubtitleStreams.Range(func(key string, value *SubtitleStream) bool {
|
||||
// if key != subtitleStreamId {
|
||||
// // If the other stream is ahead of this stream
|
||||
// // and the last subtitle event is after the other stream's offset
|
||||
// // |---------------> this stream
|
||||
// // |-------------> other stream
|
||||
// // ^^^ stop this stream where it reached the tail of the other stream
|
||||
// if offset > 0 && offset < value.offset && lastSubtitleEvent.HeadPos >= value.offset {
|
||||
// shouldEnd = true
|
||||
// }
|
||||
// }
|
||||
// return true
|
||||
// })
|
||||
// lastSubtitleEventRWMutex.RUnlock()
|
||||
// if shouldEnd {
|
||||
// subtitleStream.Stop(false)
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }()
|
||||
//
|
||||
// go func() {
|
||||
// defer func(reader io.ReadSeekCloser) {
|
||||
// _ = reader.Close()
|
||||
// s.logger.Trace().Int64("offset", offset).Msg("directstream: Closing subtitle stream goroutine")
|
||||
// }(newReader)
|
||||
// defer func() {
|
||||
// onFirstEventSent()
|
||||
// subtitleStream.cleanupFunc()
|
||||
// }()
|
||||
//
|
||||
// // Keep track if channels are active to manage loop termination
|
||||
// subtitleChannelActive := true
|
||||
// errorChannelActive := true
|
||||
//
|
||||
// for subtitleChannelActive || errorChannelActive { // Loop as long as at least one channel might still produce data or a final status
|
||||
// select {
|
||||
// case <-ctx.Done():
|
||||
// s.logger.Debug().Int64("offset", offset).Msg("directstream: Subtitle streaming cancelled by context")
|
||||
// return
|
||||
//
|
||||
// case subtitle, ok := <-subtitleCh:
|
||||
// if !ok {
|
||||
// subtitleCh = nil // Mark as exhausted
|
||||
// subtitleChannelActive = false
|
||||
// if !errorChannelActive { // If both channels are exhausted, exit
|
||||
// return
|
||||
// }
|
||||
// continue // Continue to wait for errorChannel or ctx.Done()
|
||||
// }
|
||||
// if subtitle != nil {
|
||||
// onFirstEventSent()
|
||||
// s.manager.nativePlayer.SubtitleEvent(stream.ClientId(), subtitle)
|
||||
// lastSubtitleEventRWMutex.Lock()
|
||||
// lastSubtitleEvent = subtitle
|
||||
// lastSubtitleEventRWMutex.Unlock()
|
||||
// }
|
||||
//
|
||||
// case err, ok := <-errCh:
|
||||
// if !ok {
|
||||
// errCh = nil // Mark as exhausted
|
||||
// errorChannelActive = false
|
||||
// if !subtitleChannelActive { // If both channels are exhausted, exit
|
||||
// return
|
||||
// }
|
||||
// continue // Continue to wait for subtitleChannel or ctx.Done()
|
||||
// }
|
||||
// // A value (error or nil) was received from errCh.
|
||||
// // This is the terminal signal from the mkvparser's subtitle streaming process.
|
||||
// if err != nil {
|
||||
// s.logger.Warn().Err(err).Int64("offset", offset).Msg("directstream: Error streaming subtitles")
|
||||
// } else {
|
||||
// s.logger.Info().Int64("offset", offset).Msg("directstream: Subtitle streaming completed by parser.")
|
||||
// subtitleStream.Stop(true)
|
||||
// }
|
||||
// return // Terminate goroutine
|
||||
// }
|
||||
// }
|
||||
// }()
|
||||
//}
|
||||
|
||||
//// streamSubtitles starts the subtitle stream.
|
||||
//// It will stream the subtitles from all tracks to the client. The client should load the subtitles in an array.
|
||||
//func (m *Manager) streamSubtitles(ctx context.Context, stream Stream, parser *mkvparser.MetadataParser, newReader io.ReadSeekCloser, offset int64, cleanupFunc func()) (firstEventSentCh chan struct{}) {
|
||||
// m.Logger.Debug().Int64("offset", offset).Str("clientId", stream.ClientId()).Msg("directstream: Starting subtitle extraction")
|
||||
//
|
||||
// subtitleCh, errCh, _ := parser.ExtractSubtitles(ctx, newReader, offset)
|
||||
//
|
||||
// firstEventSentCh = make(chan struct{})
|
||||
// closeFirstEventSentOnce := sync.Once{}
|
||||
//
|
||||
// onFirstEventSent := func() {
|
||||
// closeFirstEventSentOnce.Do(func() {
|
||||
// m.Logger.Debug().Int64("offset", offset).Msg("directstream: First subtitle event sent")
|
||||
// close(firstEventSentCh) // Notify that the first subtitle event has been sent
|
||||
// })
|
||||
// }
|
||||
//
|
||||
// go func() {
|
||||
// defer func(reader io.ReadSeekCloser) {
|
||||
// _ = reader.Close()
|
||||
// m.Logger.Trace().Int64("offset", offset).Msg("directstream: Closing subtitle stream goroutine")
|
||||
// }(newReader)
|
||||
// defer func() {
|
||||
// onFirstEventSent()
|
||||
// if cleanupFunc != nil {
|
||||
// cleanupFunc()
|
||||
// }
|
||||
// }()
|
||||
//
|
||||
// // Keep track if channels are active to manage loop termination
|
||||
// subtitleChannelActive := true
|
||||
// errorChannelActive := true
|
||||
//
|
||||
// for subtitleChannelActive || errorChannelActive { // Loop as long as at least one channel might still produce data or a final status
|
||||
// select {
|
||||
// case <-ctx.Done():
|
||||
// m.Logger.Debug().Int64("offset", offset).Msg("directstream: Subtitle streaming cancelled by context")
|
||||
// return
|
||||
//
|
||||
// case subtitle, ok := <-subtitleCh:
|
||||
// if !ok {
|
||||
// subtitleCh = nil // Mark as exhausted
|
||||
// subtitleChannelActive = false
|
||||
// if !errorChannelActive { // If both channels are exhausted, exit
|
||||
// return
|
||||
// }
|
||||
// continue // Continue to wait for errorChannel or ctx.Done()
|
||||
// }
|
||||
// if subtitle != nil {
|
||||
// onFirstEventSent()
|
||||
// m.nativePlayer.SubtitleEvent(stream.ClientId(), subtitle)
|
||||
// }
|
||||
//
|
||||
// case err, ok := <-errCh:
|
||||
// if !ok {
|
||||
// errCh = nil // Mark as exhausted
|
||||
// errorChannelActive = false
|
||||
// if !subtitleChannelActive { // If both channels are exhausted, exit
|
||||
// return
|
||||
// }
|
||||
// continue // Continue to wait for subtitleChannel or ctx.Done()
|
||||
// }
|
||||
// // A value (error or nil) was received from errCh.
|
||||
// // This is the terminal signal from the mkvparser's subtitle streaming process.
|
||||
// if err != nil {
|
||||
// m.Logger.Warn().Err(err).Int64("offset", offset).Msg("directstream: Error streaming subtitles")
|
||||
// } else {
|
||||
// m.Logger.Info().Int64("offset", offset).Msg("directstream: Subtitle streaming completed by parser.")
|
||||
// }
|
||||
// return // Terminate goroutine
|
||||
// }
|
||||
// }
|
||||
// }()
|
||||
//
|
||||
// return
|
||||
//}
|
||||
|
||||
// OnSubtitleFileUploaded adds a subtitle track, converts it to ASS if needed.
|
||||
func (s *BaseStream) OnSubtitleFileUploaded(filename string, content string) {
|
||||
parser, ok := s.playbackInfo.MkvMetadataParser.Get()
|
||||
if !ok {
|
||||
s.logger.Error().Msg("directstream:A Failed to load playback info")
|
||||
return
|
||||
}
|
||||
|
||||
ext := util.FileExt(filename)
|
||||
|
||||
newContent := content
|
||||
if ext != ".ass" && ext != ".ssa" {
|
||||
var err error
|
||||
var from int
|
||||
switch ext {
|
||||
case ".srt":
|
||||
from = mkvparser.SubtitleTypeSRT
|
||||
case ".vtt":
|
||||
from = mkvparser.SubtitleTypeWEBVTT
|
||||
case ".ttml":
|
||||
from = mkvparser.SubtitleTypeTTML
|
||||
case ".stl":
|
||||
from = mkvparser.SubtitleTypeSTL
|
||||
case ".txt":
|
||||
from = mkvparser.SubtitleTypeUnknown
|
||||
default:
|
||||
err = errors.New("unsupported subtitle format")
|
||||
}
|
||||
s.logger.Debug().
|
||||
Str("filename", filename).
|
||||
Str("ext", ext).
|
||||
Int("detected", from).
|
||||
Msg("directstream: Converting uploaded subtitle file")
|
||||
newContent, err = mkvparser.ConvertToASS(content, from)
|
||||
if err != nil {
|
||||
s.manager.wsEventManager.SendEventTo(s.clientId, events.ErrorToast, "Failed to convert subtitle file: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
metadata := parser.GetMetadata(context.Background())
|
||||
num := int64(len(metadata.Tracks)) + 1
|
||||
subtitleNum := int64(len(metadata.SubtitleTracks))
|
||||
|
||||
// e.g. filename = "title.eng.srt" -> name = "title.eng"
|
||||
name := strings.TrimSuffix(filename, ext)
|
||||
// e.g. "title.eng" -> ".eng" or "title.eng"
|
||||
name = strings.Replace(name, strings.Replace(s.filename, util.FileExt(s.filename), "", -1), "", 1) // remove the filename from the subtitle name
|
||||
name = strings.TrimSpace(name)
|
||||
|
||||
// e.g. name = "title.eng" -> probableLangExt = ".eng"
|
||||
probableLangExt := util.FileExt(name)
|
||||
|
||||
// if probableLangExt is not empty, use it as the language
|
||||
lang := cmp.Or(strings.TrimPrefix(probableLangExt, "."), name)
|
||||
// cleanup lang
|
||||
lang = strings.ReplaceAll(lang, "-", " ")
|
||||
lang = strings.ReplaceAll(lang, "_", " ")
|
||||
lang = strings.ReplaceAll(lang, ".", " ")
|
||||
lang = strings.ReplaceAll(lang, ",", " ")
|
||||
lang = cmp.Or(lang, fmt.Sprintf("Added track %d", num+1))
|
||||
|
||||
if name == "PLACEHOLDER" {
|
||||
name = fmt.Sprintf("External (#%d)", subtitleNum+1)
|
||||
lang = "und"
|
||||
}
|
||||
|
||||
track := &mkvparser.TrackInfo{
|
||||
Number: num,
|
||||
UID: num + 900,
|
||||
Type: mkvparser.TrackTypeSubtitle,
|
||||
CodecID: "S_TEXT/ASS",
|
||||
Name: name,
|
||||
Language: lang,
|
||||
LanguageIETF: lang,
|
||||
Default: false,
|
||||
Forced: false,
|
||||
Enabled: true,
|
||||
CodecPrivate: newContent,
|
||||
}
|
||||
|
||||
metadata.Tracks = append(metadata.Tracks, track)
|
||||
metadata.SubtitleTracks = append(metadata.SubtitleTracks, track)
|
||||
|
||||
s.logger.Debug().
|
||||
Msg("directstream: Sending subtitle file to the client")
|
||||
|
||||
s.manager.nativePlayer.AddSubtitleTrack(s.clientId, track)
|
||||
}
|
||||
1
seanime-2.9.10/internal/directstream/torrent.go
Normal file
1
seanime-2.9.10/internal/directstream/torrent.go
Normal file
@@ -0,0 +1 @@
|
||||
package directstream
|
||||
229
seanime-2.9.10/internal/directstream/torrentstream.go
Normal file
229
seanime-2.9.10/internal/directstream/torrentstream.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package directstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/library/anime"
|
||||
"seanime/internal/mkvparser"
|
||||
"seanime/internal/nativeplayer"
|
||||
"seanime/internal/util/result"
|
||||
"seanime/internal/util/torrentutil"
|
||||
|
||||
"github.com/anacrolix/torrent"
|
||||
"github.com/google/uuid"
|
||||
"github.com/samber/mo"
|
||||
)
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Torrent
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
var _ Stream = (*TorrentStream)(nil)
|
||||
|
||||
// TorrentStream is a stream that is a torrent.
|
||||
type TorrentStream struct {
|
||||
BaseStream
|
||||
torrent *torrent.Torrent
|
||||
file *torrent.File
|
||||
streamReadyCh chan struct{} // Closed by the initiator when the stream is ready
|
||||
}
|
||||
|
||||
func (s *TorrentStream) Type() nativeplayer.StreamType {
|
||||
return nativeplayer.StreamTypeTorrent
|
||||
}
|
||||
|
||||
func (s *TorrentStream) LoadContentType() string {
|
||||
s.contentTypeOnce.Do(func() {
|
||||
r := s.file.NewReader()
|
||||
defer r.Close()
|
||||
s.contentType = loadContentType(s.file.DisplayPath(), r)
|
||||
})
|
||||
|
||||
return s.contentType
|
||||
}
|
||||
|
||||
func (s *TorrentStream) LoadPlaybackInfo() (ret *nativeplayer.PlaybackInfo, err error) {
|
||||
s.playbackInfoOnce.Do(func() {
|
||||
if s.file == nil || s.torrent == nil {
|
||||
ret = &nativeplayer.PlaybackInfo{}
|
||||
err = fmt.Errorf("torrent 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)
|
||||
}
|
||||
}
|
||||
|
||||
playbackInfo := nativeplayer.PlaybackInfo{
|
||||
ID: id,
|
||||
StreamType: s.Type(),
|
||||
MimeType: s.LoadContentType(),
|
||||
StreamUrl: "{{SERVER_URL}}/api/v1/directstream/stream?id=" + id,
|
||||
ContentLength: s.file.Length(),
|
||||
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 := torrentutil.NewReadSeeker(s.torrent, s.file, s.logger)
|
||||
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(torrent): Failed to get metadata")
|
||||
s.playbackInfoErr = err
|
||||
return
|
||||
}
|
||||
|
||||
// Add subtitle tracks from subtitle files in the torrent
|
||||
s.AppendSubtitleFile(s.torrent, s.file, metadata)
|
||||
|
||||
playbackInfo.MkvMetadata = metadata
|
||||
playbackInfo.MkvMetadataParser = mo.Some(parser)
|
||||
}
|
||||
|
||||
s.playbackInfo = &playbackInfo
|
||||
})
|
||||
|
||||
return s.playbackInfo, s.playbackInfoErr
|
||||
}
|
||||
|
||||
func (s *TorrentStream) GetAttachmentByName(filename string) (*mkvparser.AttachmentInfo, bool) {
|
||||
return getAttachmentByName(s.manager.playbackCtx, s, filename)
|
||||
}
|
||||
|
||||
func (s *TorrentStream) 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(torrent): Stream endpoint hit")
|
||||
|
||||
if s.file == nil || s.torrent == nil {
|
||||
s.logger.Error().Msg("directstream(torrent): No torrent to stream")
|
||||
http.Error(w, "No torrent to stream", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
size := s.file.Length()
|
||||
contentType := s.LoadContentType()
|
||||
name := s.file.DisplayPath()
|
||||
|
||||
// Handle HEAD requests explicitly to provide file size information
|
||||
if r.Method == http.MethodHead {
|
||||
s.logger.Trace().Msg("directstream(torrent): Handling HEAD request")
|
||||
// Set the content length from torrent file
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", name))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
if isThumbnailRequest(r) {
|
||||
reader := s.file.NewReader()
|
||||
ra, ok := handleRange(w, r, reader, name, size)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
serveContentRange(w, r, r.Context(), reader, name, size, contentType, ra)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Trace().Str("file", name).Msg("directstream(torrent): New reader")
|
||||
tr := torrentutil.NewReadSeeker(s.torrent, s.file, s.logger)
|
||||
defer func() {
|
||||
s.logger.Trace().Msg("directstream(torrent): Closing reader")
|
||||
_ = tr.Close()
|
||||
}()
|
||||
|
||||
ra, ok := handleRange(w, r, tr, name, size)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := s.playbackInfo.MkvMetadataParser.Get(); ok {
|
||||
// Start a subtitle stream from the current position
|
||||
subReader := s.file.NewReader()
|
||||
subReader.SetResponsive()
|
||||
s.StartSubtitleStream(s, s.manager.playbackCtx, subReader, ra.Start)
|
||||
}
|
||||
|
||||
serveContentRange(w, r, s.manager.playbackCtx, tr, name, size, s.LoadContentType(), ra)
|
||||
})
|
||||
}
|
||||
|
||||
type PlayTorrentStreamOptions struct {
|
||||
ClientId string
|
||||
EpisodeNumber int
|
||||
AnidbEpisode string
|
||||
Media *anilist.BaseAnime
|
||||
Torrent *torrent.Torrent
|
||||
File *torrent.File
|
||||
}
|
||||
|
||||
// PlayTorrentStream is used by a module to load a new torrent stream.
|
||||
func (m *Manager) PlayTorrentStream(ctx context.Context, opts PlayTorrentStreamOptions) (chan struct{}, 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 nil, fmt.Errorf("cannot play local file, could not create episode collection: %w", err)
|
||||
}
|
||||
|
||||
episode, ok := episodeCollection.FindEpisodeByAniDB(opts.AnidbEpisode)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("cannot play torrent stream, could not find episode: %s", opts.AnidbEpisode)
|
||||
}
|
||||
|
||||
stream := &TorrentStream{
|
||||
torrent: opts.Torrent,
|
||||
file: opts.File,
|
||||
BaseStream: BaseStream{
|
||||
manager: m,
|
||||
logger: m.Logger,
|
||||
clientId: opts.ClientId,
|
||||
media: opts.Media,
|
||||
filename: filepath.Base(opts.File.DisplayPath()),
|
||||
episode: episode,
|
||||
episodeCollection: episodeCollection,
|
||||
subtitleEventCache: result.NewResultMap[string, *mkvparser.SubtitleEvent](),
|
||||
activeSubtitleStreams: result.NewResultMap[string, *SubtitleStream](),
|
||||
},
|
||||
streamReadyCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-stream.streamReadyCh
|
||||
m.loadStream(stream)
|
||||
}()
|
||||
|
||||
return stream.streamReadyCh, nil
|
||||
}
|
||||
|
||||
// AppendSubtitleFile finds the subtitle file for the torrent and appends it as a track to the metadata
|
||||
// - If there's only one subtitle file, use it
|
||||
// - If there are multiple subtitle files, use the one that matches the name of the selected torrent file
|
||||
// - If there are no subtitle files, do nothing
|
||||
//
|
||||
// If the subtitle file is not ASS/SSA, it will be converted to ASS/SSA.
|
||||
func (s *TorrentStream) AppendSubtitleFile(t *torrent.Torrent, file *torrent.File, metadata *mkvparser.Metadata) {
|
||||
|
||||
}
|
||||
38
seanime-2.9.10/internal/directstream/utils.go
Normal file
38
seanime-2.9.10/internal/directstream/utils.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package directstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"seanime/internal/mkvparser"
|
||||
)
|
||||
|
||||
func getAttachmentByName(ctx context.Context, stream Stream, filename string) (*mkvparser.AttachmentInfo, bool) {
|
||||
filename, _ = url.PathUnescape(filename)
|
||||
|
||||
container, err := stream.LoadPlaybackInfo()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
parser, ok := container.MkvMetadataParser.Get()
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
attachment, ok := parser.GetMetadata(ctx).GetAttachmentByName(filename)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return attachment, true
|
||||
}
|
||||
|
||||
func isEbmlExtension(filename string) bool {
|
||||
ext := filepath.Ext(filename)
|
||||
return ext == ".mkv" || ext == ".m4v" || ext == ".mp4"
|
||||
}
|
||||
|
||||
func isEbmlContent(mimeType string) bool {
|
||||
return mimeType == "video/x-matroska" || mimeType == "video/webm"
|
||||
}
|
||||
Reference in New Issue
Block a user