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

177 lines
5.8 KiB
Go

package mediastream
import (
"errors"
"fmt"
"seanime/internal/mediastream/videofile"
"seanime/internal/util/result"
"github.com/rs/zerolog"
"github.com/samber/mo"
)
const (
StreamTypeTranscode StreamType = "transcode" // On-the-fly transcoding
StreamTypeOptimized StreamType = "optimized" // Pre-transcoded
StreamTypeDirect StreamType = "direct" // Direct streaming
)
type (
StreamType string
PlaybackManager struct {
logger *zerolog.Logger
currentMediaContainer mo.Option[*MediaContainer] // The current media being played.
repository *Repository
mediaContainers *result.Map[string, *MediaContainer] // Temporary cache for the media containers.
}
PlaybackState struct {
MediaId int `json:"mediaId"` // The media ID
}
MediaContainer struct {
Filepath string `json:"filePath"`
Hash string `json:"hash"`
StreamType StreamType `json:"streamType"` // Tells the frontend how to play the media.
StreamUrl string `json:"streamUrl"` // The relative endpoint to stream the media.
MediaInfo *videofile.MediaInfo `json:"mediaInfo"`
//Metadata *Metadata `json:"metadata"`
// todo: add more fields (e.g. metadata)
}
)
func NewPlaybackManager(repository *Repository) *PlaybackManager {
return &PlaybackManager{
logger: repository.logger,
repository: repository,
mediaContainers: result.NewResultMap[string, *MediaContainer](),
}
}
func (p *PlaybackManager) KillPlayback() {
p.logger.Debug().Msg("mediastream: Killing playback")
if p.currentMediaContainer.IsPresent() {
p.currentMediaContainer = mo.None[*MediaContainer]()
p.logger.Trace().Msg("mediastream: Removed current media container")
}
}
// RequestPlayback is called by the frontend to stream a media file
func (p *PlaybackManager) RequestPlayback(filepath string, streamType StreamType) (ret *MediaContainer, err error) {
p.logger.Debug().Str("filepath", filepath).Any("type", streamType).Msg("mediastream: Requesting playback")
// Create a new media container
ret, err = p.newMediaContainer(filepath, streamType)
if err != nil {
p.logger.Error().Err(err).Msg("mediastream: Failed to create media container")
return nil, fmt.Errorf("failed to create media container: %v", err)
}
// Set the current media container.
p.currentMediaContainer = mo.Some(ret)
p.logger.Info().Str("filepath", filepath).Msg("mediastream: Ready to play media")
return
}
// PreloadPlayback is called by the frontend to preload a media container so that the data is stored in advanced
func (p *PlaybackManager) PreloadPlayback(filepath string, streamType StreamType) (ret *MediaContainer, err error) {
p.logger.Debug().Str("filepath", filepath).Any("type", streamType).Msg("mediastream: Preloading playback")
// Create a new media container
ret, err = p.newMediaContainer(filepath, streamType)
if err != nil {
p.logger.Error().Err(err).Msg("mediastream: Failed to create media container")
return nil, fmt.Errorf("failed to create media container: %v", err)
}
p.logger.Info().Str("filepath", filepath).Msg("mediastream: Ready to play media")
return
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Optimize
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (p *PlaybackManager) newMediaContainer(filepath string, streamType StreamType) (ret *MediaContainer, err error) {
p.logger.Debug().Str("filepath", filepath).Any("type", streamType).Msg("mediastream: New media container requested")
// Get the hash of the file.
hash, err := videofile.GetHashFromPath(filepath)
if err != nil {
return nil, err
}
p.logger.Trace().Str("hash", hash).Msg("mediastream: Checking cache")
// Check the cache ONLY if the stream type is the same.
if mc, ok := p.mediaContainers.Get(hash); ok && mc.StreamType == streamType {
p.logger.Debug().Str("hash", hash).Msg("mediastream: Media container cache HIT")
return mc, nil
}
p.logger.Trace().Str("hash", hash).Msg("mediastream: Creating media container")
// Get the media information of the file.
ret = &MediaContainer{
Filepath: filepath,
Hash: hash,
StreamType: streamType,
}
p.logger.Debug().Msg("mediastream: Extracting media info")
ret.MediaInfo, err = p.repository.mediaInfoExtractor.GetInfo(p.repository.settings.MustGet().FfprobePath, filepath)
if err != nil {
return nil, err
}
p.logger.Debug().Msg("mediastream: Extracted media info, extracting attachments")
// Extract the attachments from the file.
err = videofile.ExtractAttachment(p.repository.settings.MustGet().FfmpegPath, filepath, hash, ret.MediaInfo, p.repository.cacheDir, p.logger)
if err != nil {
p.logger.Error().Err(err).Msg("mediastream: Failed to extract attachments")
return nil, err
}
p.logger.Debug().Msg("mediastream: Extracted attachments")
streamUrl := ""
switch streamType {
case StreamTypeDirect:
// Directly serve the file.
streamUrl = "/api/v1/mediastream/direct"
case StreamTypeTranscode:
// Live transcode the file.
streamUrl = "/api/v1/mediastream/transcode/master.m3u8"
case StreamTypeOptimized:
// TODO: Check if the file is already transcoded when the feature is implemented.
// ...
streamUrl = "/api/v1/mediastream/hls/master.m3u8"
}
// TODO: Add metadata to the media container.
// ...
if streamUrl == "" {
return nil, errors.New("invalid stream type")
}
// Set the stream URL.
ret.StreamUrl = streamUrl
// Store the media container in the map.
p.mediaContainers.Set(hash, ret)
return
}