feat(subtitles): on-demand AI subtitle translation with live streaming
Add server-side AI subtitle translation backed by any OpenAI-compatible chat endpoint (OpenAI, Groq, a local Ollama/llama.cpp server). A viewer picks a source track and target language in the player; the server runs a bounded, resumable job pipeline that translates SRT/VTT cues in batches and streams them back over the realtime websocket so playback pauses, fills in cues near the playhead, and resumes. The finished track is persisted as an ordinary downloaded subtitle, so it reaches every client through the existing subtitle pipeline with no client changes. - Job lifecycle persisted in subtitle_ai_jobs (migration 168): enqueue with idempotency, bounded concurrency, progress/heartbeat, cancellation, and crash recovery. - New realtime events (subtitle_ready + subtitle_translation_*) with a per-session notifier; the player renders a synthetic "live" track fed by websocket cues. Timestamps never leave the server, so timing can't drift. - Admin settings card for endpoint / model / concurrency. Player + lifecycle hardening (from the code review of this feature): - Hand off from the live track to the persisted track on completion (selected by downloaded-subtitle id) and on the subtitle_ready broadcast, so the saved track survives a reload and a mid-stream socket drop. - Never persist the synthetic live-track sentinel index as a subtitle preference; restore the prior selection on failure; only auto-resume playback if the viewer was actually playing. - Resume promptly when the playhead is past the last cue; rebuild the live track on a new job; O(batch) live-cue ingestion instead of O(n^2). Reliability: - Root translation jobs in the application context so shutdown cancels them. - Heartbeat-based stale-job reaper (safe across multiple instances) replaces the table-wide startup reset. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1082,6 +1082,7 @@ var sensitiveSettingKeys = map[string]bool{
|
||||
"mdblist.api_key": true,
|
||||
"requests.radarr.api_key": true,
|
||||
"requests.sonarr.api_key": true,
|
||||
"subtitle_ai.api_key": true,
|
||||
"watchsync.trakt.client_id": true,
|
||||
"watchsync.trakt.client_secret": true,
|
||||
"watchsync.simkl.client_id": true,
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles/ai"
|
||||
)
|
||||
|
||||
// SubtitleAIHandler exposes on-demand AI subtitle translation backed by the
|
||||
// configured OpenAI-compatible engine. Generated tracks are stored as ordinary
|
||||
// downloaded subtitles, so they reach every client through the existing
|
||||
// subtitle pipeline.
|
||||
type SubtitleAIHandler struct {
|
||||
service *ai.Service
|
||||
FileAuthorizer *MediaFileAuthorizer
|
||||
}
|
||||
|
||||
// NewSubtitleAIHandler creates a handler backed by the given service.
|
||||
func NewSubtitleAIHandler(service *ai.Service) *SubtitleAIHandler {
|
||||
return &SubtitleAIHandler{service: service}
|
||||
}
|
||||
|
||||
// authorizeMediaFileAccess verifies the caller may access the given media file.
|
||||
// Shared by the subtitle handlers so authorization stays in one place.
|
||||
func authorizeMediaFileAccess(w http.ResponseWriter, r *http.Request, authorizer *MediaFileAuthorizer, fileID int) bool {
|
||||
if authorizer == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Media file authorization is not configured")
|
||||
return false
|
||||
}
|
||||
if _, err := authorizer.Authorize(r, fileID); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, catalog.ErrItemNotFound), errors.Is(err, catalog.ErrEpisodeNotFound):
|
||||
writeError(w, http.StatusNotFound, "not_found", "Media file not found")
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize media file")
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type translateSubtitleRequest struct {
|
||||
MediaFileID int `json:"media_file_id"`
|
||||
SourceIndex int `json:"source_index"`
|
||||
SourceLanguage string `json:"source_language"`
|
||||
TargetLanguage string `json:"target_language"`
|
||||
SessionID string `json:"session_id"`
|
||||
StartPosition float64 `json:"start_position"`
|
||||
}
|
||||
|
||||
// HandleStatus reports whether AI subtitle translation is available, so the
|
||||
// player can show or hide the entry point. GET /api/v1/subtitles/ai/status
|
||||
func (h *SubtitleAIHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"enabled": h.service.Enabled()})
|
||||
}
|
||||
|
||||
// WriteSubtitleAIDisabledStatus answers the AI status capability probe with a
|
||||
// 200 {"enabled": false} when no AI handler is wired, so the client gets a clean
|
||||
// negative instead of a 404 (the 2-segment /ai/status path is not shadowed by the
|
||||
// 1-segment /{media_file_id} route — they never compete in chi's router).
|
||||
func WriteSubtitleAIDisabledStatus(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"enabled": false})
|
||||
}
|
||||
|
||||
// HandleTranslate enqueues a translation job. POST /api/v1/subtitles/ai/translate
|
||||
func (h *SubtitleAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Request) {
|
||||
var req translateSubtitleRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if req.MediaFileID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "media_file_id is required")
|
||||
return
|
||||
}
|
||||
if req.TargetLanguage == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "target_language is required")
|
||||
return
|
||||
}
|
||||
|
||||
if !authorizeMediaFileAccess(w, r, h.FileAuthorizer, req.MediaFileID) {
|
||||
return
|
||||
}
|
||||
|
||||
var requestedBy *int
|
||||
if userID := apimw.GetUserID(r.Context()); userID != 0 {
|
||||
requestedBy = &userID
|
||||
}
|
||||
|
||||
job, err := h.service.Enqueue(r.Context(), ai.JobRequest{
|
||||
MediaFileID: req.MediaFileID,
|
||||
Kind: ai.JobKindTranslate,
|
||||
SourceIndex: req.SourceIndex,
|
||||
SourceLanguage: req.SourceLanguage,
|
||||
TargetLanguage: req.TargetLanguage,
|
||||
RequestedBy: requestedBy,
|
||||
SessionID: req.SessionID,
|
||||
StartPosition: req.StartPosition,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ai.ErrEngineNotConfigured):
|
||||
writeError(w, http.StatusServiceUnavailable, "not_configured",
|
||||
"AI subtitle translation is not configured on this server")
|
||||
case errors.Is(err, ai.ErrInvalidRequest):
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
default:
|
||||
slog.Error("failed to enqueue subtitle translation",
|
||||
"media_file_id", req.MediaFileID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start translation")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"job": job})
|
||||
}
|
||||
|
||||
// HandleGetJob returns a job's current state. GET /api/v1/subtitles/ai/jobs/{job_id}
|
||||
func (h *SubtitleAIHandler) HandleGetJob(w http.ResponseWriter, r *http.Request) {
|
||||
job, ok := h.loadAuthorizedJob(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"job": job})
|
||||
}
|
||||
|
||||
// HandleCancelJob cancels a job. POST /api/v1/subtitles/ai/jobs/{job_id}/cancel
|
||||
func (h *SubtitleAIHandler) HandleCancelJob(w http.ResponseWriter, r *http.Request) {
|
||||
job, ok := h.loadAuthorizedJob(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.service.Cancel(r.Context(), job.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel job")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleListJobs lists recent jobs for a media file.
|
||||
// GET /api/v1/subtitles/ai/jobs?media_file_id=N
|
||||
func (h *SubtitleAIHandler) HandleListJobs(w http.ResponseWriter, r *http.Request) {
|
||||
mediaFileID, err := strconv.Atoi(r.URL.Query().Get("media_file_id"))
|
||||
if err != nil || mediaFileID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_id", "Invalid or missing media_file_id")
|
||||
return
|
||||
}
|
||||
if !authorizeMediaFileAccess(w, r, h.FileAuthorizer, mediaFileID) {
|
||||
return
|
||||
}
|
||||
jobs, err := h.service.ListJobs(r.Context(), mediaFileID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list_error", "Failed to list jobs")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"jobs": jobs})
|
||||
}
|
||||
|
||||
// loadAuthorizedJob parses the job_id param, loads the job, and authorizes
|
||||
// access against its media file. It writes the error response on failure.
|
||||
func (h *SubtitleAIHandler) loadAuthorizedJob(w http.ResponseWriter, r *http.Request) (*ai.Job, bool) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "job_id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_id", "Invalid job ID")
|
||||
return nil, false
|
||||
}
|
||||
job, err := h.service.GetJob(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, ai.ErrJobNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Job not found")
|
||||
return nil, false
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load job")
|
||||
return nil, false
|
||||
}
|
||||
if !authorizeMediaFileAccess(w, r, h.FileAuthorizer, job.MediaFileID) {
|
||||
return nil, false
|
||||
}
|
||||
return job, true
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"strings"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -96,20 +95,7 @@ type downloadSubtitleRequest struct {
|
||||
}
|
||||
|
||||
func (h *SubtitleSearchHandler) authorizeMediaFile(w http.ResponseWriter, r *http.Request, fileID int) bool {
|
||||
if h.FileAuthorizer == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Media file authorization is not configured")
|
||||
return false
|
||||
}
|
||||
if _, err := h.FileAuthorizer.Authorize(r, fileID); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, catalog.ErrItemNotFound), errors.Is(err, catalog.ErrEpisodeNotFound):
|
||||
writeError(w, http.StatusNotFound, "not_found", "Media file not found")
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize media file")
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
return authorizeMediaFileAccess(w, r, h.FileAuthorizer, fileID)
|
||||
}
|
||||
|
||||
// HandleSearch handles POST /api/v1/subtitles/search
|
||||
|
||||
+59
-2
@@ -54,6 +54,7 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/scanqueue"
|
||||
"github.com/Silo-Server/silo-server/internal/sections"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
subtitleai "github.com/Silo-Server/silo-server/internal/subtitles/ai"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles/opensubtitles"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles/subdl"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles/subsource"
|
||||
@@ -515,6 +516,11 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
subtitleRepo = subtitles.NewPgRepository(deps.DB)
|
||||
}
|
||||
|
||||
// Notifier that pushes "subtitle ready" events to active sessions when an AI
|
||||
// translation completes. Assigned inside the playback handler block where the
|
||||
// realtime hub and session manager are in scope; nil when playback is off.
|
||||
var subtitleAINotifier *playback.SubtitleReadyNotifier
|
||||
|
||||
// Build playback handler if session manager is available.
|
||||
var playbackHandler *handlers.PlaybackHandler
|
||||
var adminPlaybackControlHandler *handlers.AdminPlaybackControlHandler
|
||||
@@ -609,6 +615,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
playbackHandler.MarkerUpserter = deps.FileRepo
|
||||
}
|
||||
playbackHandler.MarkerUpdateNotifier = playback.NewMarkerUpdateNotifier(deps.SessionMgr, realtimeHub)
|
||||
subtitleAINotifier = playback.NewSubtitleReadyNotifier(deps.SessionMgr, realtimeHub)
|
||||
adminPlaybackControlHandler = handlers.NewAdminPlaybackControlHandler(playbackHandler)
|
||||
|
||||
if deps.DB != nil && deps.FileRepo != nil && viewerResolver != nil && deps.Config != nil && detailSvc != nil {
|
||||
@@ -772,6 +779,40 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
adminSubtitleHandler.SetDownloadedSubtitleDeps(deps.DB, subtitleManager)
|
||||
}
|
||||
|
||||
// Build the AI subtitle handler (on-demand translation). Generated tracks are
|
||||
// stored as ordinary downloaded subtitles, so they reach every client through
|
||||
// the existing subtitle pipeline with no client changes.
|
||||
var subtitleAIHandler *handlers.SubtitleAIHandler
|
||||
if subtitleManager != nil && subtitleRepo != nil && deps.FileRepo != nil && deps.DB != nil && deps.Config != nil {
|
||||
aiCfg := subtitleai.Config{
|
||||
Enabled: deps.Config.SubtitleAI.Enabled,
|
||||
BaseURL: deps.Config.SubtitleAI.BaseURL,
|
||||
APIKey: deps.Config.SubtitleAI.APIKey,
|
||||
ChatModel: deps.Config.SubtitleAI.ChatModel,
|
||||
MaxConcurrentJobs: deps.Config.SubtitleAI.MaxConcurrentJobs,
|
||||
BatchSize: deps.Config.SubtitleAI.BatchSize,
|
||||
ContextNeighbors: deps.Config.SubtitleAI.ContextNeighbors,
|
||||
}
|
||||
var aiNotifier subtitleai.Notifier
|
||||
if subtitleAINotifier != nil {
|
||||
aiNotifier = subtitleAINotifier
|
||||
}
|
||||
aiService := subtitleai.NewService(
|
||||
deps.AppContext,
|
||||
aiCfg,
|
||||
subtitleai.NewPgJobRepository(deps.DB),
|
||||
subtitleai.NewLLMTranslator(subtitleai.NewClient(aiCfg), aiCfg.BatchSize, aiCfg.ContextNeighbors),
|
||||
subtitleManager,
|
||||
subtitleRepo,
|
||||
deps.FileRepo,
|
||||
aiNotifier,
|
||||
deps.Config.Playback.FFmpegPath,
|
||||
slog.Default(),
|
||||
)
|
||||
aiService.Recover()
|
||||
subtitleAIHandler = handlers.NewSubtitleAIHandler(aiService)
|
||||
}
|
||||
|
||||
// Build section handler if DB is available.
|
||||
var sectionHandler *handlers.SectionHandler
|
||||
var sectionSettingsHandler *handlers.SectionSettingsHandler
|
||||
@@ -1541,20 +1582,36 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
})
|
||||
}
|
||||
|
||||
// Subtitle search routes.
|
||||
// Subtitle search + AI translation routes.
|
||||
if subtitleSearchHandler != nil {
|
||||
if deps.FileRepo != nil && itemRepo != nil {
|
||||
subtitleSearchHandler.FileAuthorizer = &handlers.MediaFileAuthorizer{
|
||||
fileAuthorizer := &handlers.MediaFileAuthorizer{
|
||||
FileResolver: deps.FileRepo,
|
||||
ItemAccess: itemRepo,
|
||||
EpisodeLookup: episodeRepo,
|
||||
}
|
||||
subtitleSearchHandler.FileAuthorizer = fileAuthorizer
|
||||
if subtitleAIHandler != nil {
|
||||
subtitleAIHandler.FileAuthorizer = fileAuthorizer
|
||||
}
|
||||
}
|
||||
r.Route("/subtitles", func(r chi.Router) {
|
||||
r.Post("/search", subtitleSearchHandler.HandleSearch)
|
||||
r.Post("/download", subtitleSearchHandler.HandleDownload)
|
||||
r.Post("/upload", subtitleSearchHandler.HandleUpload)
|
||||
r.Post("/detect-language", subtitleSearchHandler.HandleDetectLanguage)
|
||||
if subtitleAIHandler != nil {
|
||||
r.Get("/ai/status", subtitleAIHandler.HandleStatus)
|
||||
r.Post("/ai/translate", subtitleAIHandler.HandleTranslate)
|
||||
r.Get("/ai/jobs", subtitleAIHandler.HandleListJobs)
|
||||
r.Get("/ai/jobs/{job_id}", subtitleAIHandler.HandleGetJob)
|
||||
r.Post("/ai/jobs/{job_id}/cancel", subtitleAIHandler.HandleCancelJob)
|
||||
} else {
|
||||
// Answer the capability probe with 200 {"enabled": false}
|
||||
// when AI translation isn't wired, so the client gets a
|
||||
// clean negative instead of a 404.
|
||||
r.Get("/ai/status", handlers.WriteSubtitleAIDisabledStatus)
|
||||
}
|
||||
r.Get("/{media_file_id}", subtitleSearchHandler.HandleList)
|
||||
r.Delete("/{id}", subtitleSearchHandler.HandleDelete)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user