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>
80 lines
2.3 KiB
Go
80 lines
2.3 KiB
Go
package ai
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestParseCuesSRT(t *testing.T) {
|
|
in := "1\n00:00:01,000 --> 00:00:02,500\nHello world\n\n" +
|
|
"2\n00:00:03,000 --> 00:00:04,000\nLine one\nLine two\n"
|
|
|
|
cues, err := ParseCues([]byte(in))
|
|
if err != nil {
|
|
t.Fatalf("ParseCues: %v", err)
|
|
}
|
|
if len(cues) != 2 {
|
|
t.Fatalf("got %d cues, want 2", len(cues))
|
|
}
|
|
if cues[0].Start != time.Second || cues[0].End != 2500*time.Millisecond {
|
|
t.Errorf("cue 0 timing = %v..%v", cues[0].Start, cues[0].End)
|
|
}
|
|
if len(cues[0].Lines) != 1 || cues[0].Lines[0] != "Hello world" {
|
|
t.Errorf("cue 0 lines = %#v", cues[0].Lines)
|
|
}
|
|
if len(cues[1].Lines) != 2 {
|
|
t.Errorf("cue 1 lines = %#v", cues[1].Lines)
|
|
}
|
|
}
|
|
|
|
func TestParseCuesVTT(t *testing.T) {
|
|
in := "WEBVTT\n\n" +
|
|
"00:00:01.000 --> 00:00:02.500 line:80%\nHello\n\n" +
|
|
"NOTE a comment block\n\n" +
|
|
"00:01:00.000 --> 00:01:01.000\nWorld\n"
|
|
|
|
cues, err := ParseCues([]byte(in))
|
|
if err != nil {
|
|
t.Fatalf("ParseCues: %v", err)
|
|
}
|
|
if len(cues) != 2 {
|
|
t.Fatalf("got %d cues, want 2 (NOTE block must be skipped)", len(cues))
|
|
}
|
|
if cues[0].Start != time.Second || cues[0].End != 2500*time.Millisecond {
|
|
t.Errorf("cue 0 timing = %v..%v", cues[0].Start, cues[0].End)
|
|
}
|
|
if cues[1].Start != time.Minute {
|
|
t.Errorf("cue 1 start = %v, want 1m", cues[1].Start)
|
|
}
|
|
}
|
|
|
|
func TestSerializeRoundTrip(t *testing.T) {
|
|
orig := []SubtitleCue{
|
|
{Start: time.Second, End: 2500 * time.Millisecond, Lines: []string{"Hello world"}},
|
|
{Start: 3 * time.Second, End: 4 * time.Second, Lines: []string{"Line one", "Line two"}},
|
|
}
|
|
|
|
reparsed, err := ParseCues(SerializeSRT(orig))
|
|
if err != nil {
|
|
t.Fatalf("reparse: %v", err)
|
|
}
|
|
if len(reparsed) != len(orig) {
|
|
t.Fatalf("round-trip changed cue count: got %d, want %d", len(reparsed), len(orig))
|
|
}
|
|
for i := range orig {
|
|
if reparsed[i].Start != orig[i].Start || reparsed[i].End != orig[i].End {
|
|
t.Errorf("cue %d timing drifted: %v..%v vs %v..%v",
|
|
i, reparsed[i].Start, reparsed[i].End, orig[i].Start, orig[i].End)
|
|
}
|
|
if len(reparsed[i].Lines) != len(orig[i].Lines) {
|
|
t.Errorf("cue %d line count changed: %#v", i, reparsed[i].Lines)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseCuesEmpty(t *testing.T) {
|
|
if _, err := ParseCues([]byte("not a subtitle file")); err == nil {
|
|
t.Error("expected error for input with no cues")
|
|
}
|
|
}
|