* 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>
213 lines
6.5 KiB
Go
213 lines
6.5 KiB
Go
// Package aitranslate implements generic batched text translation over an
|
|
// OpenAI-compatible chat model. Callers hand it ordered text segments and a
|
|
// domain-specific system prompt; it owns the wire protocol (indexed-JSON
|
|
// batches the model must echo back key-for-key), context carryover between
|
|
// batches, and retries on malformed model output. Subtitle cues and metadata
|
|
// descriptions both translate through this one implementation.
|
|
package aitranslate
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Segment is one unit of translatable text. ID is the caller's identifier and
|
|
// is never sent to the model (batches are keyed by position), so any stable
|
|
// string works.
|
|
type Segment struct {
|
|
ID string
|
|
Text string
|
|
}
|
|
|
|
// ChatFn performs one chat completion. Wiring this as a function keeps the
|
|
// package independent of the client and trivially testable.
|
|
type ChatFn func(ctx context.Context, system, user string) (string, error)
|
|
|
|
// Request describes one translation run.
|
|
type Request struct {
|
|
Segments []Segment
|
|
SystemPrompt string // domain-specific; must demand same-keys JSON output
|
|
// TargetName is the human-readable target language name used in the
|
|
// per-batch user message.
|
|
TargetName string
|
|
// EntryNoun names the units in the user message ("cues", "descriptions");
|
|
// empty defaults to "entries".
|
|
EntryNoun string
|
|
// BatchSize is the number of segments per chat request (required, > 0).
|
|
BatchSize int
|
|
// ContextNeighbors is how many preceding source segments are included,
|
|
// untranslated, for continuity across batch boundaries.
|
|
ContextNeighbors int
|
|
}
|
|
|
|
// maxFormatRetries is how many times a batch is re-asked after the model
|
|
// returns unparsable or incomplete JSON. Transport/API errors are retried
|
|
// inside the chat client and are not part of this count.
|
|
const maxFormatRetries = 2
|
|
|
|
// Translate translates req.Segments in order, preserving count and IDs and
|
|
// only rewriting Text. onBatch, when non-nil, is called after each batch with
|
|
// that batch's translated segments and overall progress (done/total
|
|
// segments). Implementations must honor ctx cancellation between batches.
|
|
func Translate(ctx context.Context, chat ChatFn, req Request, onBatch func(batch []Segment, done, total int)) ([]Segment, error) {
|
|
if chat == nil {
|
|
return nil, fmt.Errorf("chat function is nil")
|
|
}
|
|
if req.BatchSize <= 0 {
|
|
return nil, fmt.Errorf("invalid batch size: %d", req.BatchSize)
|
|
}
|
|
total := len(req.Segments)
|
|
if total == 0 {
|
|
return nil, fmt.Errorf("no segments to translate")
|
|
}
|
|
noun := req.EntryNoun
|
|
if noun == "" {
|
|
noun = "entries"
|
|
}
|
|
|
|
// Preserve IDs by copying input segments and only replacing Text.
|
|
out := make([]Segment, total)
|
|
copy(out, req.Segments)
|
|
|
|
for start := 0; start < total; start += req.BatchSize {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
end := min(start+req.BatchSize, total)
|
|
|
|
contextStart := max(0, start-req.ContextNeighbors)
|
|
translated, err := translateBatch(ctx, chat, req, noun, req.Segments[contextStart:start], req.Segments[start:end])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("translate %s %d-%d: %w", noun, start+1, end, err)
|
|
}
|
|
for i, text := range translated {
|
|
out[start+i].Text = text
|
|
}
|
|
if onBatch != nil {
|
|
onBatch(out[start:end], end, total)
|
|
}
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func translateBatch(ctx context.Context, chat ChatFn, req Request, noun string, contextSegs, batch []Segment) ([]string, error) {
|
|
texts := make([]string, len(batch))
|
|
for i, s := range batch {
|
|
texts[i] = s.Text
|
|
}
|
|
payload, err := buildIndexedJSON(texts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var user strings.Builder
|
|
if len(contextSegs) > 0 {
|
|
user.WriteString("Preceding lines for context only — do not translate or include them in your output:\n")
|
|
for _, s := range contextSegs {
|
|
user.WriteString(strings.ReplaceAll(s.Text, "\n", " "))
|
|
user.WriteByte('\n')
|
|
}
|
|
user.WriteByte('\n')
|
|
}
|
|
target := req.TargetName
|
|
if target == "" {
|
|
target = "the target language"
|
|
}
|
|
fmt.Fprintf(&user, "Translate these %d %s into %s. Respond with only the JSON object:\n%s", len(batch), noun, target, payload)
|
|
|
|
var lastErr error
|
|
for attempt := 0; attempt <= maxFormatRetries; attempt++ {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
content, err := chat(ctx, req.SystemPrompt, user.String())
|
|
if err != nil {
|
|
return nil, err // transport/API errors are already retried inside the client
|
|
}
|
|
|
|
obj, err := extractJSONObject(content)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
var m map[string]string
|
|
if err := json.Unmarshal([]byte(obj), &m); err != nil {
|
|
lastErr = fmt.Errorf("decode translation JSON: %w", err)
|
|
continue
|
|
}
|
|
|
|
out := make([]string, len(batch))
|
|
complete := true
|
|
for i := range batch {
|
|
v, ok := m[strconv.Itoa(i+1)]
|
|
if !ok {
|
|
complete = false
|
|
break
|
|
}
|
|
out[i] = v
|
|
}
|
|
if !complete {
|
|
lastErr = fmt.Errorf("model omitted one or more %s", noun)
|
|
continue
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("invalid model response after %d attempts: %w", maxFormatRetries+1, lastErr)
|
|
}
|
|
|
|
// buildIndexedJSON renders texts as a JSON object {"1":..., "2":...} keyed by
|
|
// 1-based position, escaping each value safely. It is built by hand rather
|
|
// than json.Marshal'ing a map so the keys stay in numeric order — that reads
|
|
// more naturally for the model than the lexicographic order Go emits for maps
|
|
// ("1","10","11",...,"2"). Correctness doesn't depend on order (results are
|
|
// mapped back by key), but ordered input gives the model better context.
|
|
func buildIndexedJSON(texts []string) (string, error) {
|
|
var b strings.Builder
|
|
b.WriteByte('{')
|
|
for i, text := range texts {
|
|
if i > 0 {
|
|
b.WriteByte(',')
|
|
}
|
|
key, err := json.Marshal(strconv.Itoa(i + 1))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
val, err := json.Marshal(text)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
b.Write(key)
|
|
b.WriteByte(':')
|
|
b.Write(val)
|
|
}
|
|
b.WriteByte('}')
|
|
return b.String(), nil
|
|
}
|
|
|
|
// extractJSONObject pulls the first balanced-looking JSON object out of a
|
|
// model reply, tolerating ``` code fences and surrounding prose.
|
|
func extractJSONObject(s string) (string, error) {
|
|
s = strings.TrimSpace(s)
|
|
if strings.HasPrefix(s, "```") {
|
|
s = strings.TrimPrefix(s, "```")
|
|
if nl := strings.IndexByte(s, '\n'); nl >= 0 {
|
|
s = s[nl+1:]
|
|
}
|
|
if idx := strings.LastIndex(s, "```"); idx >= 0 {
|
|
s = s[:idx]
|
|
}
|
|
s = strings.TrimSpace(s)
|
|
}
|
|
start := strings.IndexByte(s, '{')
|
|
end := strings.LastIndexByte(s, '}')
|
|
if start < 0 || end < 0 || end < start {
|
|
return "", fmt.Errorf("no JSON object found in model response")
|
|
}
|
|
return s[start : end+1], nil
|
|
}
|