2026-05-29 23:59:12 -04:00
|
|
|
package playback
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"log/slog"
|
2026-08-10 18:14:49 -04:00
|
|
|
|
|
|
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
2026-05-29 23:59:12 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type subtitleReadySessionLookup interface {
|
|
|
|
|
GetSessionsByMediaFileID(fileID int) []*Session
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 18:14:49 -04:00
|
|
|
// SubtitleInventoryResolver resolves the frozen combined-ordinal inventory for
|
|
|
|
|
// a file so realtime events can name a new track by its ordinal, identity, and
|
|
|
|
|
// stream URL rather than leaving the client to reconstruct one.
|
|
|
|
|
type SubtitleInventoryResolver interface {
|
|
|
|
|
MediaFile(ctx context.Context, fileID int) (*models.MediaFile, error)
|
|
|
|
|
AdditionalSubtitles(ctx context.Context, file *models.MediaFile) ([]SubtitleInventoryEntryV3, error)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 23:59:12 -04:00
|
|
|
// SubtitleReadyNotifier pushes "subtitle ready" events to active playback
|
|
|
|
|
// sessions when a generated subtitle track (AI translation, later ASR) becomes
|
|
|
|
|
// available, so the player can refresh and select it without a manual reload.
|
|
|
|
|
//
|
|
|
|
|
// It satisfies the subtitles/ai Notifier interface structurally, keeping the ai
|
|
|
|
|
// package free of any playback dependency.
|
|
|
|
|
type SubtitleReadyNotifier struct {
|
2026-08-10 18:14:49 -04:00
|
|
|
sessions subtitleReadySessionLookup
|
|
|
|
|
hub *RealtimeHub
|
|
|
|
|
inventory SubtitleInventoryResolver
|
2026-05-29 23:59:12 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewSubtitleReadyNotifier returns a notifier, or nil if its dependencies are
|
2026-08-10 18:14:49 -04:00
|
|
|
// missing (callers treat a nil notifier as a no-op). A nil inventory resolver
|
|
|
|
|
// is allowed: events then omit the track block and the client refetches its
|
|
|
|
|
// plan to learn the new ordinal.
|
|
|
|
|
func NewSubtitleReadyNotifier(sessions subtitleReadySessionLookup, hub *RealtimeHub, inventory SubtitleInventoryResolver) *SubtitleReadyNotifier {
|
2026-05-29 23:59:12 -04:00
|
|
|
if sessions == nil || hub == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-08-10 18:14:49 -04:00
|
|
|
return &SubtitleReadyNotifier{sessions: sessions, hub: hub, inventory: inventory}
|
2026-05-29 23:59:12 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SubtitleReady notifies active sessions for the file that a new subtitle track
|
|
|
|
|
// with the given downloaded-subtitle ID is available.
|
2026-07-09 20:53:52 +08:00
|
|
|
func (n *SubtitleReadyNotifier) SubtitleReady(ctx context.Context, mediaFileID, subtitleID int, language, label string) {
|
2026-05-29 23:59:12 -04:00
|
|
|
if n == nil || mediaFileID <= 0 || subtitleID <= 0 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, session := range n.sessions.GetSessionsByMediaFileID(mediaFileID) {
|
|
|
|
|
if session == nil || session.ID == "" || !session.HasRealtimeConnection {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-08-10 18:14:49 -04:00
|
|
|
track := n.resolveTrack(ctx, session.ID, mediaFileID, subtitleID)
|
|
|
|
|
event, err := NewSubtitleReadyEvent(session.ID, mediaFileID, subtitleID, language, label, track)
|
2026-05-29 23:59:12 -04:00
|
|
|
if err != nil {
|
2026-07-09 20:53:52 +08:00
|
|
|
slog.WarnContext(ctx, "failed to encode subtitle ready realtime event", "component", "playback",
|
2026-05-29 23:59:12 -04:00
|
|
|
"session_id", session.ID, "file_id", mediaFileID, "subtitle_id", subtitleID, "error", err)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if err := n.hub.Send(session.ID, event); err != nil && !errors.Is(err, ErrRealtimeConnectionNotFound) {
|
2026-07-09 20:53:52 +08:00
|
|
|
slog.WarnContext(ctx, "failed to deliver subtitle ready realtime event", "component", "playback",
|
2026-05-29 23:59:12 -04:00
|
|
|
"session_id", session.ID, "file_id", mediaFileID, "subtitle_id", subtitleID, "error", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TranslationStarted tells one session a live translation has begun.
|
2026-07-09 20:53:52 +08:00
|
|
|
func (n *SubtitleReadyNotifier) TranslationStarted(ctx context.Context, sessionID string, fileID int, jobID int64, trackKey, language, label string, totalCues int) {
|
2026-05-29 23:59:12 -04:00
|
|
|
n.sendTranslation(sessionID, func() (EventEnvelope, error) {
|
|
|
|
|
return NewSubtitleTranslationStartedEvent(sessionID, fileID, jobID, trackKey, language, label, totalCues)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TranslationCues pushes a batch of translated cues to one session.
|
2026-07-09 20:53:52 +08:00
|
|
|
func (n *SubtitleReadyNotifier) TranslationCues(ctx context.Context, sessionID string, fileID int, jobID int64, trackKey string, cues []StreamCue, done, total int) {
|
2026-05-29 23:59:12 -04:00
|
|
|
n.sendTranslation(sessionID, func() (EventEnvelope, error) {
|
|
|
|
|
return NewSubtitleTranslationCuesEvent(sessionID, fileID, jobID, trackKey, cues, done, total)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TranslationCompleted tells one session a live translation finished.
|
2026-07-09 20:53:52 +08:00
|
|
|
func (n *SubtitleReadyNotifier) TranslationCompleted(ctx context.Context, sessionID string, fileID int, jobID int64, trackKey string, subtitleID int, language, label string) {
|
2026-08-10 18:14:49 -04:00
|
|
|
track := n.resolveTrack(ctx, sessionID, fileID, subtitleID)
|
2026-05-29 23:59:12 -04:00
|
|
|
n.sendTranslation(sessionID, func() (EventEnvelope, error) {
|
2026-08-10 18:14:49 -04:00
|
|
|
return NewSubtitleTranslationCompletedEvent(sessionID, fileID, jobID, trackKey, subtitleID, language, label, track)
|
2026-05-29 23:59:12 -04:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TranslationFailed tells one session a live translation failed.
|
2026-07-09 20:53:52 +08:00
|
|
|
func (n *SubtitleReadyNotifier) TranslationFailed(ctx context.Context, sessionID string, fileID int, jobID int64, trackKey, message string) {
|
2026-05-29 23:59:12 -04:00
|
|
|
n.sendTranslation(sessionID, func() (EventEnvelope, error) {
|
|
|
|
|
return NewSubtitleTranslationFailedEvent(sessionID, fileID, jobID, trackKey, message)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 18:14:49 -04:00
|
|
|
// resolveTrack looks up the newly persisted track's inventory entry. The
|
|
|
|
|
// subtitle row is already committed when these events fire, so the entry it
|
|
|
|
|
// resolves to carries the same ordinal the next plan will publish.
|
|
|
|
|
//
|
|
|
|
|
// The row ID is server-private but retained on each inventory item, so match it
|
|
|
|
|
// exactly. Language/label are presentation metadata and need not be unique.
|
|
|
|
|
func (n *SubtitleReadyNotifier) resolveTrack(ctx context.Context, sessionID string, fileID, subtitleID int) *SubtitleInventoryItemV3 {
|
|
|
|
|
if n == nil || n.inventory == nil || fileID <= 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
file, err := n.inventory.MediaFile(ctx, fileID)
|
|
|
|
|
if err != nil || file == nil {
|
|
|
|
|
slog.WarnContext(ctx, "subtitle realtime event omits track identity", "component", "playback",
|
|
|
|
|
"file_id", fileID, "error", err)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
additional, err := n.inventory.AdditionalSubtitles(ctx, file)
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.WarnContext(ctx, "subtitle realtime event omits track identity", "component", "playback",
|
|
|
|
|
"file_id", fileID, "error", err)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
items := SubtitleInventoryV3(sessionID, file, additional)
|
|
|
|
|
for i := range items {
|
|
|
|
|
if items[i].Source != SubtitleSourceDownloadedV3 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if items[i].downloadedSubtitleID == subtitleID {
|
|
|
|
|
track := items[i]
|
|
|
|
|
return &track
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 23:59:12 -04:00
|
|
|
// sendTranslation builds and delivers a translation event to a single session.
|
|
|
|
|
func (n *SubtitleReadyNotifier) sendTranslation(sessionID string, build func() (EventEnvelope, error)) {
|
|
|
|
|
if n == nil || n.hub == nil || sessionID == "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
event, err := build()
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Warn("failed to encode subtitle translation realtime event", "session_id", sessionID, "error", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := n.hub.Send(sessionID, event); err != nil && !errors.Is(err, ErrRealtimeConnectionNotFound) {
|
|
|
|
|
slog.Warn("failed to deliver subtitle translation realtime event", "session_id", sessionID, "error", err)
|
|
|
|
|
}
|
|
|
|
|
}
|