node build fixed

This commit is contained in:
ra_ma
2025-09-20 14:08:38 +01:00
parent c6ebbe069d
commit 3d298fa434
1516 changed files with 535727 additions and 2 deletions

View 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
}

View 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
}

View 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
}

View 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)
}

View 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()
}

View 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()
}

View 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)
}

View File

@@ -0,0 +1 @@
package directstream

View 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) {
}

View 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"
}