Files
seanime-docker/seanime-2.9.10/internal/directstream/subtitles.go
2025-09-20 14:08:38 +01:00

523 lines
17 KiB
Go

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