* docs: design + plan for shared AI core, metadata translation, Whisper ASR Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(ai): shared LLM client, segment translator, and job runner packages internal/ai/llm: OpenAI-compatible chat client moved out of subtitles/ai, plus /v1/audio/transcriptions (verbose_json) for the ASR work; one shared retry/backoff loop for both. internal/ai/translate: the batched indexed-JSON translation protocol generalized to text segments. internal/ai/jobrunner: dispatch/heartbeat/reaper/cancel lifecycle extracted behind a minimal store interface, with a semaphore shareable across job services. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(subtitles): consume shared AI core LLMTranslator becomes a thin cue<->segment adapter over aitranslate; the service delegates dispatch/heartbeat/reaper/cancel to jobrunner; the local OpenAI client is gone in favor of internal/ai/llm. Behavior (prompts, wire protocol, job rows, recovery semantics) is unchanged. NewService now takes the dispatch semaphore so all AI job services can share one bound. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(config): shared ai.* settings, metadata translation job table, localization provenance columns ai.* connection keys (chat + optional separate ASR endpoint) load with a fallback to the legacy subtitle_ai.* rows — those are never renamed in SQL because encrypted values are GCM-bound to their setting key. New toggles: subtitle_ai.transcribe_enabled, metadata_ai.enabled. Migration adds metadata_translation_jobs, per-field provenance (provider|ai|manual) on the localization tables, and media_folders.auto_translate_metadata. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(catalog): localization field provenance with provider/ai/manual precedence Provider upserts keep manual values and never blank a field with an empty incoming value; new UpsertAITranslation/UpsertAIOverview methods write AI fields only over empty or ai-sourced values (force adds provider, never manual) — all enforced in single-statement SQL. Serving now merges only non-empty localized fields onto the base item, since localization rows are legitimately partial (AI rows carry no titles/artwork). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(metadata): AI translation service, refresh auto-fallback, and admin API internal/metadata/translation: job service over the shared AI core that expands an item to its season/episode overviews, skips already-localized fields (zero model calls on repeat runs), batches paragraphs through the generic translator, and persists per batch with provenance-aware upserts. MetadataService gains an AutoTranslator seam invoked after each refresh for libraries with auto_translate_metadata. Admin endpoints under the metadata curation guard: enqueue, list (poll), cancel; plus a status probe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(subtitles): Whisper ASR transcribe and transcribe_translate jobs New WhisperTranscriber: one ffmpeg pass extracts the audio track to 10-min 16kHz mono WAV chunks (temp dir cleaned on every exit path), each chunk goes to the OpenAI-compatible /v1/audio/transcriptions endpoint (verbose_json, per-request timeout sized to 3x chunk duration), segment timestamps are offset and built into wrapped cues. Chunks process playhead-first and stream live to the requesting session. The transcript is stored as an ordinary downloaded subtitle (provider 'transcribed'); transcribe_translate chains the existing translator and stores the translated track as the job result. Enqueue accepts an optional kind; status reports transcribe_enabled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): AI services settings, metadata translate action, library auto-translate, generate-from-audio New AI Services admin page hosts the shared endpoint config (reads fall back to legacy subtitle_ai.* values, writes target ai.*) and the three feature toggles; the AI card moves out of Subtitles settings. The metadata editor gains a Translate-with-AI panel with job polling and force/re-translate. The library form gains the auto-translate toggle (threaded through the libraries API). The player translate modal gains a From-audio mode that lists audio tracks and submits transcribe / transcribe_translate jobs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style: gofmt import grouping in router and translation tests Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(catalog): per-profile metadata language and viewer-triggered description translation user_profiles.preferred_metadata_language threads through the access scope into catalog serving: presentation language now resolves explicit param -> profile preference -> library metadata language (native API and jellycompat). ItemDetail gains pending_translation_language when the viewer's language is missing a localized overview. New metadata_ai.on_view setting (off|button| auto) gates POST /items/{id}/translate-description: any profile with item access may request its language, with in-flight dedup and a 15-minute failure cooldown so page views never hammer a broken endpoint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): on-view description translation with per-profile metadata language Profile playback settings gain a Metadata language picker (library default inherit). Detail pages: when the server reports pending_translation_language and metadata_ai.on_view is 'auto', the description translates on view with a pulse animation until the refetched detail comes back localized (45s timeout); in 'button' mode a small Translate chip triggers the same flow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web): expose metadata_ai.on_view in AI Services settings The on-view translation mode had no UI control, so it could only ever be 'off' — viewers got neither the auto translation nor the fallback button. Adds the off/button/auto selector to the Features card, and the config loader now warns and falls back to 'off' on a bad row instead of refusing to start. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ai): clear configuration hint when the transcription endpoint is chat-only A blank Transcription base URL falls back to the chat endpoint; chat-only gateways reject the multipart upload with an opaque 400 that reads like a pipeline bug. 400/404/405 transcription failures now carry a hint to set a Whisper-compatible endpoint in AI Services. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(subtitles): wrap ASR cue text by rune count, not bytes Arabic/Cyrillic/Greek text is 2+ bytes per character in UTF-8, so byte-based wrapping broke lines at roughly half the intended visual width. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(web): steer transcription base URL hint away from chat-only gateways Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ai): block chat-only gateways for transcription, add endpoint presets llm.IsChatOnlyGateway (OpenRouter et al — no timestamped transcription API) is enforced in three layers: the settings API rejects ai.asr_base_url values pointing at one, the router disables ASR with a warning when the blank-URL fallback would land on one, and llm.Transcribe refuses outright. The AI Services page gains one-click transcription presets (Groq turbo/accurate, OpenAI, self-hosted speaches) plus the mirrored client-side check, and the settings API now also validates metadata_ai.on_view. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(subtitles): tighten ASR subtitle sync Three systematic timing-error sources addressed: cue offsets now use the segment muxer's exact per-chunk start times (segment_list CSV) instead of assuming index*chunk_seconds; the audio stream's start delay relative to the container timeline (common in TS remuxes) is probed via ffprobe and added to every cue; and the chunk length is now operator-tunable via subtitle_ai.asr_chunk_seconds (60-600s, default 600) since shorter chunks bound Whisper's within-chunk timestamp drift. Playhead-first ordering now pivots on real chunk starts, and a beyond-end playhead starts at the final chunk instead of restarting from zero. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ai): tolerate base URLs that already include the /v1 segment Providers like DeepInfra expose their OpenAI-compatible API under a base that contains the version segment (api.deepinfra.com/v1/openai); always appending /v1/... mangled those. endpointURL now appends bare paths when the base already carries /v1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): prefer self-hosted transcription in presets and hints Preset order becomes self-hosted (recommended) -> Groq turbo -> Groq large-v3 -> OpenAI, and the settings hint plus the job-error hint lead with the self-hosted option. The self-hosted preset now fills the turbo CT2 model to match the recommended speaches setup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(subtitles): request VAD and word timestamps for ASR cue accuracy Without vad_filter, faster-whisper servers report wall-to-wall segment times: cues linger on screen through silence (verified up to 91s) and paragraph-length segments become single 400+ char cues. Request vad_filter=true (skipped for hosted providers that reject non-OpenAI fields and run VAD server-side) plus timestamp_granularities word+segment, and rebuild cues from word timings: split at speech pauses, sentence ends, text capacity, and a 7s max duration; cap word-less segments instead of trusting their reported end; stretch sub-second cues to a readable minimum. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
545 lines
16 KiB
Go
545 lines
16 KiB
Go
package translation
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/ai/jobrunner"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
)
|
|
|
|
// --- fakes ---
|
|
|
|
type fakeRepo struct {
|
|
mu sync.Mutex
|
|
nextID int64
|
|
jobs map[int64]*Job
|
|
completed chan int64
|
|
}
|
|
|
|
func newFakeRepo() *fakeRepo {
|
|
return &fakeRepo{jobs: map[int64]*Job{}, completed: make(chan int64, 16)}
|
|
}
|
|
|
|
func (r *fakeRepo) InsertJob(_ context.Context, job *Job) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.nextID++
|
|
job.ID = r.nextID
|
|
cp := *job
|
|
r.jobs[job.ID] = &cp
|
|
return nil
|
|
}
|
|
|
|
func (r *fakeRepo) GetJob(_ context.Context, id int64) (*Job, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if j, ok := r.jobs[id]; ok {
|
|
cp := *j
|
|
return &cp, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (r *fakeRepo) GetActiveJobByIdempotencyKey(_ context.Context, key string) (*Job, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
for _, j := range r.jobs {
|
|
if j.IdempotencyKey == key && !j.Status.Terminal() {
|
|
cp := *j
|
|
return &cp, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (r *fakeRepo) ListJobsByContent(_ context.Context, contentID string) ([]Job, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
var out []Job
|
|
// Newest first, matching the SQL implementation's ORDER BY created_at DESC.
|
|
for id := r.nextID; id >= 1; id-- {
|
|
if j, ok := r.jobs[id]; ok && j.ContentID == contentID {
|
|
out = append(out, *j)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *fakeRepo) UpdateProgress(_ context.Context, id int64, status JobStatus, progress float64, message string, done, total int) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if j, ok := r.jobs[id]; ok && !j.Status.Terminal() {
|
|
j.Status, j.Progress, j.ProgressMessage, j.FieldsDone, j.FieldsTotal = status, progress, message, done, total
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *fakeRepo) CompleteJob(_ context.Context, id int64, message string, done, total int) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if j, ok := r.jobs[id]; ok && !j.Status.Terminal() {
|
|
j.Status, j.Progress, j.ProgressMessage, j.FieldsDone, j.FieldsTotal = jobrunner.StatusCompleted, 1, message, done, total
|
|
}
|
|
select {
|
|
case r.completed <- id:
|
|
default:
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *fakeRepo) FailJob(_ context.Context, id int64, status JobStatus, message string) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if j, ok := r.jobs[id]; ok && !j.Status.Terminal() {
|
|
j.Status, j.ErrorMessage = status, message
|
|
}
|
|
select {
|
|
case r.completed <- id:
|
|
default:
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *fakeRepo) Heartbeat(context.Context, int64) error { return nil }
|
|
func (r *fakeRepo) ResetStaleJobs(context.Context, time.Time, string) (int64, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
func (r *fakeRepo) job(id int64) Job {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return *r.jobs[id]
|
|
}
|
|
|
|
type fakeContent struct {
|
|
item *ItemText
|
|
seasons []ChildText
|
|
episodes []ChildText
|
|
missing int
|
|
}
|
|
|
|
func (c *fakeContent) ItemText(context.Context, string) (*ItemText, error) { return c.item, nil }
|
|
func (c *fakeContent) SeasonTexts(context.Context, string) ([]ChildText, error) {
|
|
return c.seasons, nil
|
|
}
|
|
func (c *fakeContent) EpisodeTexts(context.Context, string) ([]ChildText, error) {
|
|
return c.episodes, nil
|
|
}
|
|
func (c *fakeContent) SeasonByID(context.Context, string) (*ChildText, string, error) {
|
|
if len(c.seasons) == 0 {
|
|
return nil, "", nil
|
|
}
|
|
return &c.seasons[0], c.item.ContentID, nil
|
|
}
|
|
func (c *fakeContent) EpisodeByID(context.Context, string) (*ChildText, string, error) {
|
|
if len(c.episodes) == 0 {
|
|
return nil, "", nil
|
|
}
|
|
return &c.episodes[0], c.item.ContentID, nil
|
|
}
|
|
func (c *fakeContent) CountMissingFields(context.Context, string, string) (int, error) {
|
|
return c.missing, nil
|
|
}
|
|
|
|
type aiWrite struct {
|
|
kind TargetKind
|
|
contentID string
|
|
overview string
|
|
tagline string
|
|
force bool
|
|
}
|
|
|
|
type fakeLocs struct {
|
|
mu sync.Mutex
|
|
itemLoc *models.MediaItemLocalization
|
|
seasonLocs map[string]*models.SeasonLocalization
|
|
episodeLocs map[string]*models.EpisodeLocalization
|
|
writes []aiWrite
|
|
failOnUpsert bool
|
|
}
|
|
|
|
func (l *fakeLocs) ItemLocalization(context.Context, string, string) (*models.MediaItemLocalization, error) {
|
|
return l.itemLoc, nil
|
|
}
|
|
|
|
func (l *fakeLocs) SeasonLocalizations(context.Context, []string, string) (map[string]*models.SeasonLocalization, error) {
|
|
if l.seasonLocs == nil {
|
|
return map[string]*models.SeasonLocalization{}, nil
|
|
}
|
|
return l.seasonLocs, nil
|
|
}
|
|
|
|
func (l *fakeLocs) EpisodeLocalizations(context.Context, []string, string) (map[string]*models.EpisodeLocalization, error) {
|
|
if l.episodeLocs == nil {
|
|
return map[string]*models.EpisodeLocalization{}, nil
|
|
}
|
|
return l.episodeLocs, nil
|
|
}
|
|
|
|
func (l *fakeLocs) UpsertItemAI(_ context.Context, contentID, _ string, overview, tagline *string, force bool) error {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
if l.failOnUpsert {
|
|
return fmt.Errorf("boom")
|
|
}
|
|
w := aiWrite{kind: TargetItem, contentID: contentID, force: force}
|
|
if overview != nil {
|
|
w.overview = *overview
|
|
}
|
|
if tagline != nil {
|
|
w.tagline = *tagline
|
|
}
|
|
l.writes = append(l.writes, w)
|
|
return nil
|
|
}
|
|
|
|
func (l *fakeLocs) UpsertSeasonAI(_ context.Context, contentID, _ string, overview string, force bool) error {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
if l.failOnUpsert {
|
|
return fmt.Errorf("boom")
|
|
}
|
|
l.writes = append(l.writes, aiWrite{kind: TargetSeason, contentID: contentID, overview: overview, force: force})
|
|
return nil
|
|
}
|
|
|
|
func (l *fakeLocs) UpsertEpisodeAI(_ context.Context, contentID, _ string, overview string, force bool) error {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
if l.failOnUpsert {
|
|
return fmt.Errorf("boom")
|
|
}
|
|
l.writes = append(l.writes, aiWrite{kind: TargetEpisode, contentID: contentID, overview: overview, force: force})
|
|
return nil
|
|
}
|
|
|
|
func (l *fakeLocs) allWrites() []aiWrite {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
return append([]aiWrite(nil), l.writes...)
|
|
}
|
|
|
|
// upperChat "translates" by upper-casing each indexed value; counts calls.
|
|
type upperChat struct {
|
|
mu sync.Mutex
|
|
calls int
|
|
}
|
|
|
|
func (c *upperChat) fn(_ context.Context, _ string, user string) (string, error) {
|
|
c.mu.Lock()
|
|
c.calls++
|
|
c.mu.Unlock()
|
|
start := strings.IndexByte(user, '{')
|
|
var m map[string]string
|
|
if err := json.Unmarshal([]byte(user[start:]), &m); err != nil {
|
|
return "", err
|
|
}
|
|
out := make(map[string]string, len(m))
|
|
for k, v := range m {
|
|
out[k] = strings.ToUpper(v)
|
|
}
|
|
b, _ := json.Marshal(out)
|
|
return string(b), nil
|
|
}
|
|
|
|
func (c *upperChat) callCount() int {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.calls
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func testService(t *testing.T, repo *fakeRepo, content *fakeContent, locs *fakeLocs, chat *upperChat) *Service {
|
|
t.Helper()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
cfg := Config{Enabled: true, Configured: true, ChatModel: "test-model", OnView: "auto"}
|
|
return NewService(ctx, cfg, repo, content, locs, chat.fn, jobrunner.NewSemaphore(1), nil)
|
|
}
|
|
|
|
func waitDone(t *testing.T, repo *fakeRepo) int64 {
|
|
t.Helper()
|
|
select {
|
|
case id := <-repo.completed:
|
|
return id
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("job never reached a terminal state")
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func seriesContent() *fakeContent {
|
|
return &fakeContent{
|
|
item: &ItemText{
|
|
ContentID: "series1", Type: "series", Title: "Test Show", Year: 2020,
|
|
Overview: "A show.", Tagline: "Watch it.", DefaultLanguage: "en",
|
|
},
|
|
seasons: []ChildText{
|
|
{ContentID: "sea1", SeasonNumber: 1, Overview: "Season one."},
|
|
{ContentID: "sea2", SeasonNumber: 2, Overview: ""}, // no base text
|
|
},
|
|
episodes: []ChildText{
|
|
{ContentID: "ep1", SeasonNumber: 1, EpisodeNumber: 1, Overview: "Pilot episode."},
|
|
{ContentID: "ep2", SeasonNumber: 1, EpisodeNumber: 2, Overview: "Second episode."},
|
|
},
|
|
}
|
|
}
|
|
|
|
// --- tests ---
|
|
|
|
func TestSeriesJobExpandsChildrenAndPersists(t *testing.T) {
|
|
repo, content, locs, chat := newFakeRepo(), seriesContent(), &fakeLocs{}, &upperChat{}
|
|
svc := testService(t, repo, content, locs, chat)
|
|
|
|
job, err := svc.Enqueue(context.Background(), JobRequest{
|
|
ContentID: "series1", TargetLanguage: "fr", IncludeChildren: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Enqueue: %v", err)
|
|
}
|
|
waitDone(t, repo)
|
|
|
|
final := repo.job(job.ID)
|
|
if final.Status != jobrunner.StatusCompleted {
|
|
t.Fatalf("status = %s (%s)", final.Status, final.ErrorMessage)
|
|
}
|
|
// item overview + tagline + 1 season with text + 2 episodes = 5 fields.
|
|
if final.FieldsTotal != 5 || final.FieldsDone != 5 {
|
|
t.Errorf("fields = %d/%d, want 5/5", final.FieldsDone, final.FieldsTotal)
|
|
}
|
|
writes := locs.allWrites()
|
|
if len(writes) != 5 {
|
|
t.Fatalf("writes = %d, want 5: %+v", len(writes), writes)
|
|
}
|
|
if writes[0].kind != TargetItem || writes[0].overview != "A SHOW." {
|
|
t.Errorf("first write = %+v", writes[0])
|
|
}
|
|
var sawSeason, sawTagline bool
|
|
for _, w := range writes {
|
|
if w.kind == TargetSeason && w.contentID == "sea1" && w.overview == "SEASON ONE." {
|
|
sawSeason = true
|
|
}
|
|
if w.kind == TargetItem && w.tagline == "WATCH IT." {
|
|
sawTagline = true
|
|
}
|
|
if w.contentID == "sea2" {
|
|
t.Error("season without base text was translated")
|
|
}
|
|
}
|
|
if !sawSeason || !sawTagline {
|
|
t.Errorf("missing expected writes: %+v", writes)
|
|
}
|
|
}
|
|
|
|
func TestSkipIfFilledShortCircuitsWithoutModelCalls(t *testing.T) {
|
|
repo, content, chat := newFakeRepo(), seriesContent(), &upperChat{}
|
|
locs := &fakeLocs{
|
|
itemLoc: &models.MediaItemLocalization{
|
|
Overview: "Déjà traduit.", OverviewSource: "provider",
|
|
Tagline: "Déjà.", TaglineSource: "ai",
|
|
},
|
|
seasonLocs: map[string]*models.SeasonLocalization{
|
|
"sea1": {Overview: "Saison.", OverviewSource: "ai"},
|
|
},
|
|
episodeLocs: map[string]*models.EpisodeLocalization{
|
|
"ep1": {Overview: "Pilote.", OverviewSource: "provider"},
|
|
"ep2": {Overview: "Deux.", OverviewSource: "manual"},
|
|
},
|
|
}
|
|
svc := testService(t, repo, content, locs, chat)
|
|
|
|
job, err := svc.Enqueue(context.Background(), JobRequest{
|
|
ContentID: "series1", TargetLanguage: "fr", IncludeChildren: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Enqueue: %v", err)
|
|
}
|
|
waitDone(t, repo)
|
|
|
|
final := repo.job(job.ID)
|
|
if final.Status != jobrunner.StatusCompleted || final.FieldsTotal != 0 {
|
|
t.Fatalf("job = %+v, want completed with 0 fields", final)
|
|
}
|
|
if chat.callCount() != 0 {
|
|
t.Errorf("model called %d times for a fully translated item", chat.callCount())
|
|
}
|
|
}
|
|
|
|
func TestForceRetranslatesProviderAndAIButNeverManual(t *testing.T) {
|
|
repo, content, chat := newFakeRepo(), seriesContent(), &upperChat{}
|
|
locs := &fakeLocs{
|
|
itemLoc: &models.MediaItemLocalization{
|
|
Overview: "Vieux.", OverviewSource: "provider",
|
|
Tagline: "Manuel.", TaglineSource: "manual",
|
|
},
|
|
episodeLocs: map[string]*models.EpisodeLocalization{
|
|
"ep1": {Overview: "IA.", OverviewSource: "ai"},
|
|
"ep2": {Overview: "Manuel.", OverviewSource: "manual"},
|
|
},
|
|
}
|
|
svc := testService(t, repo, content, locs, chat)
|
|
|
|
job, err := svc.Enqueue(context.Background(), JobRequest{
|
|
ContentID: "series1", TargetLanguage: "fr", IncludeChildren: true, Force: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Enqueue: %v", err)
|
|
}
|
|
waitDone(t, repo)
|
|
|
|
final := repo.job(job.ID)
|
|
// item overview (provider, forced) + sea1 + ep1 (ai, forced) = 3;
|
|
// tagline and ep2 are manual and stay untouched even with force.
|
|
if final.FieldsTotal != 3 {
|
|
t.Fatalf("fields_total = %d, want 3", final.FieldsTotal)
|
|
}
|
|
for _, w := range locs.allWrites() {
|
|
if !w.force {
|
|
t.Errorf("write without force flag: %+v", w)
|
|
}
|
|
if w.tagline != "" || w.contentID == "ep2" {
|
|
t.Errorf("manual field was translated: %+v", w)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPersistFailureFailsJob(t *testing.T) {
|
|
repo, content, chat := newFakeRepo(), seriesContent(), &upperChat{}
|
|
locs := &fakeLocs{failOnUpsert: true}
|
|
svc := testService(t, repo, content, locs, chat)
|
|
|
|
job, err := svc.Enqueue(context.Background(), JobRequest{
|
|
ContentID: "series1", TargetLanguage: "fr", IncludeChildren: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Enqueue: %v", err)
|
|
}
|
|
waitDone(t, repo)
|
|
|
|
final := repo.job(job.ID)
|
|
if final.Status != jobrunner.StatusFailed || !strings.Contains(final.ErrorMessage, "boom") {
|
|
t.Fatalf("job = %+v, want failed with persist error", final)
|
|
}
|
|
}
|
|
|
|
func TestEnqueueDeduplicatesActiveJobs(t *testing.T) {
|
|
repo, content, locs, chat := newFakeRepo(), seriesContent(), &fakeLocs{}, &upperChat{}
|
|
// Block the runner so the first job stays active during the second enqueue.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
sem := jobrunner.NewSemaphore(1)
|
|
sem <- struct{}{} // occupy the only slot
|
|
cfg := Config{Enabled: true, Configured: true, ChatModel: "test-model"}
|
|
svc := NewService(ctx, cfg, repo, content, locs, chat.fn, sem, nil)
|
|
|
|
first, err := svc.Enqueue(context.Background(), JobRequest{ContentID: "series1", TargetLanguage: "fr"})
|
|
if err != nil {
|
|
t.Fatalf("Enqueue: %v", err)
|
|
}
|
|
second, err := svc.Enqueue(context.Background(), JobRequest{ContentID: "series1", TargetLanguage: "fr"})
|
|
if err != nil {
|
|
t.Fatalf("Enqueue dup: %v", err)
|
|
}
|
|
if second.ID != first.ID {
|
|
t.Errorf("duplicate enqueue created job %d, want %d", second.ID, first.ID)
|
|
}
|
|
<-sem // release so cleanup can proceed
|
|
}
|
|
|
|
func TestAutoEnqueueSkipsWhenNothingMissing(t *testing.T) {
|
|
repo, content, locs, chat := newFakeRepo(), seriesContent(), &fakeLocs{}, &upperChat{}
|
|
content.missing = 0
|
|
svc := testService(t, repo, content, locs, chat)
|
|
|
|
svc.AutoEnqueue(context.Background(), "series1", "fr")
|
|
if len(repo.jobs) != 0 {
|
|
t.Errorf("AutoEnqueue created a job with nothing missing")
|
|
}
|
|
|
|
content.missing = 3
|
|
svc.AutoEnqueue(context.Background(), "series1", "fr")
|
|
if len(repo.jobs) != 1 {
|
|
t.Errorf("AutoEnqueue did not create a job with fields missing")
|
|
}
|
|
waitDone(t, repo)
|
|
}
|
|
|
|
func TestEnqueueValidatesInput(t *testing.T) {
|
|
repo, content, locs, chat := newFakeRepo(), seriesContent(), &fakeLocs{}, &upperChat{}
|
|
svc := testService(t, repo, content, locs, chat)
|
|
|
|
if _, err := svc.Enqueue(context.Background(), JobRequest{ContentID: "x", TargetLanguage: "definitely-not-a-language"}); err == nil {
|
|
t.Error("invalid language accepted")
|
|
}
|
|
if _, err := svc.Enqueue(context.Background(), JobRequest{TargetLanguage: "fr"}); err == nil {
|
|
t.Error("missing content id accepted")
|
|
}
|
|
if _, err := svc.Enqueue(context.Background(), JobRequest{ContentID: "x", TargetKind: "bogus", TargetLanguage: "fr"}); err == nil {
|
|
t.Error("bogus target kind accepted")
|
|
}
|
|
|
|
disabled := NewService(context.Background(), Config{}, repo, content, locs, chat.fn, nil, nil)
|
|
if _, err := disabled.Enqueue(context.Background(), JobRequest{ContentID: "x", TargetLanguage: "fr"}); err != ErrNotConfigured {
|
|
t.Errorf("disabled service err = %v, want ErrNotConfigured", err)
|
|
}
|
|
}
|
|
|
|
func TestRequestOnViewCooldownAndGating(t *testing.T) {
|
|
repo, content, locs, chat := newFakeRepo(), seriesContent(), &fakeLocs{}, &upperChat{}
|
|
svc := testService(t, repo, content, locs, chat)
|
|
|
|
// A recently failed job for the same target+language suppresses re-enqueue.
|
|
failed := &Job{
|
|
ContentID: "series1", TargetKind: TargetItem, TargetLanguage: "fr",
|
|
Status: jobrunner.StatusFailed, UpdatedAt: time.Now().Add(-time.Minute),
|
|
}
|
|
if err := repo.InsertJob(context.Background(), failed); err != nil {
|
|
t.Fatalf("seed failed job: %v", err)
|
|
}
|
|
repo.mu.Lock()
|
|
repo.jobs[failed.ID].Status = jobrunner.StatusFailed
|
|
repo.jobs[failed.ID].UpdatedAt = time.Now().Add(-time.Minute)
|
|
repo.mu.Unlock()
|
|
|
|
job, err := svc.RequestOnView(context.Background(), "series1", "fr", nil)
|
|
if err != nil {
|
|
t.Fatalf("RequestOnView: %v", err)
|
|
}
|
|
if job.ID != failed.ID || job.Status != jobrunner.StatusFailed {
|
|
t.Fatalf("cooldown not applied: got job %d status %s", job.ID, job.Status)
|
|
}
|
|
|
|
// Once the failure has aged past the cooldown, a new job is enqueued.
|
|
repo.mu.Lock()
|
|
repo.jobs[failed.ID].UpdatedAt = time.Now().Add(-2 * onViewFailureCooldown)
|
|
repo.mu.Unlock()
|
|
job, err = svc.RequestOnView(context.Background(), "series1", "fr", nil)
|
|
if err != nil {
|
|
t.Fatalf("RequestOnView after cooldown: %v", err)
|
|
}
|
|
if job.ID == failed.ID {
|
|
t.Fatal("stale failed job returned after cooldown expired")
|
|
}
|
|
waitDone(t, repo)
|
|
|
|
// A different language is unaffected by the failure.
|
|
job2, err := svc.RequestOnView(context.Background(), "series1", "de", nil)
|
|
if err != nil || job2.ID == failed.ID {
|
|
t.Fatalf("other-language request blocked: job=%v err=%v", job2, err)
|
|
}
|
|
waitDone(t, repo)
|
|
|
|
// OnView off (zero-value config) refuses viewer requests outright.
|
|
off := NewService(context.Background(), Config{Enabled: true, Configured: true, ChatModel: "m"}, repo, content, locs, chat.fn, nil, nil)
|
|
if _, err := off.RequestOnView(context.Background(), "series1", "fr", nil); err != ErrNotConfigured {
|
|
t.Errorf("on_view=off err = %v, want ErrNotConfigured", err)
|
|
}
|
|
}
|