* 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>
198 lines
6.0 KiB
Go
198 lines
6.0 KiB
Go
// Package jobrunner owns the lifecycle mechanics shared by Silo's AI job
|
||
// services (subtitle translation/ASR, metadata translation): bounded dispatch
|
||
// off a semaphore shared across services, a heartbeat loop that keeps a row
|
||
// alive even while the job is queued, a stale-job reaper for rows orphaned by
|
||
// a crashed worker, and a per-job cancel registry. The services keep their own
|
||
// job tables, validation, and run logic; this package keeps them honest about
|
||
// concurrency and crash recovery without copy-pasting the trickiest code.
|
||
package jobrunner
|
||
|
||
import (
|
||
"context"
|
||
"log/slog"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
const (
|
||
// A running job refreshes its heartbeat every HeartbeatInterval; one whose
|
||
// heartbeat has not advanced for StaleJobThreshold is treated as orphaned
|
||
// by a crashed worker and reaped. The margin over HeartbeatInterval avoids
|
||
// reaping a job that is merely mid–LLM-call.
|
||
HeartbeatInterval = 30 * time.Second
|
||
StaleJobThreshold = 2 * time.Minute
|
||
// How often the background reaper scans for orphaned jobs.
|
||
ReaperInterval = time.Minute
|
||
)
|
||
|
||
// Store is the minimal persistence surface the runner needs. Both AI job
|
||
// repositories satisfy it.
|
||
type Store interface {
|
||
Heartbeat(ctx context.Context, id int64) error
|
||
// ResetStaleJobs marks pending/running jobs whose heartbeat predates
|
||
// `before` as failed with the given message. Returns rows reset.
|
||
ResetStaleJobs(ctx context.Context, before time.Time, message string) (int64, error)
|
||
}
|
||
|
||
// NewSemaphore builds the dispatch semaphore shared across runners, so the
|
||
// configured endpoint sees one global bound regardless of job mix. size <= 0
|
||
// falls back to 2.
|
||
func NewSemaphore(size int) chan struct{} {
|
||
if size <= 0 {
|
||
size = 2
|
||
}
|
||
return make(chan struct{}, size)
|
||
}
|
||
|
||
// Runner executes jobs with bounded concurrency, heartbeats, cancellation,
|
||
// and crash recovery. One Runner per job table; the semaphore may be shared
|
||
// across Runners.
|
||
type Runner struct {
|
||
// baseCtx is the application context; dispatched jobs and the reaper
|
||
// derive from it so they stop when the server shuts down.
|
||
baseCtx context.Context
|
||
sem chan struct{}
|
||
store Store
|
||
// label names the job family in log lines ("subtitle ai", "metadata translation").
|
||
label string
|
||
logger *slog.Logger
|
||
|
||
mu sync.Mutex
|
||
cancels map[int64]context.CancelFunc
|
||
wg sync.WaitGroup
|
||
}
|
||
|
||
// New wires a runner. A nil appCtx falls back to context.Background(); a nil
|
||
// sem gets a private default-size semaphore (used by tests; production passes
|
||
// the shared one).
|
||
func New(appCtx context.Context, sem chan struct{}, store Store, label string, logger *slog.Logger) *Runner {
|
||
if appCtx == nil {
|
||
appCtx = context.Background()
|
||
}
|
||
if sem == nil {
|
||
sem = NewSemaphore(0)
|
||
}
|
||
if logger == nil {
|
||
logger = slog.Default()
|
||
}
|
||
return &Runner{
|
||
baseCtx: appCtx,
|
||
sem: sem,
|
||
store: store,
|
||
label: label,
|
||
logger: logger,
|
||
cancels: make(map[int64]context.CancelFunc),
|
||
}
|
||
}
|
||
|
||
// Recover clears jobs orphaned by a crashed worker and starts a background
|
||
// reaper that keeps doing so. Reaping is heartbeat-based (not "every active
|
||
// job"), so it is safe when multiple instances share one database: a job
|
||
// still being heartbeat-updated by a live worker is never reset. Call once at
|
||
// startup; jobs and the reaper derive from the application context, so they
|
||
// stop on shutdown.
|
||
func (r *Runner) Recover() {
|
||
r.reapStaleJobs()
|
||
go r.reaperLoop()
|
||
}
|
||
|
||
func (r *Runner) reaperLoop() {
|
||
ticker := time.NewTicker(ReaperInterval)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-r.baseCtx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
r.reapStaleJobs()
|
||
}
|
||
}
|
||
}
|
||
|
||
func (r *Runner) reapStaleJobs() {
|
||
before := time.Now().Add(-StaleJobThreshold)
|
||
n, err := r.store.ResetStaleJobs(context.WithoutCancel(r.baseCtx), before, "interrupted by server restart")
|
||
if err != nil {
|
||
r.logger.Warn("failed to reset stale jobs", "jobs", r.label, "error", err)
|
||
return
|
||
}
|
||
if n > 0 {
|
||
r.logger.Info("reset stale jobs", "jobs", r.label, "count", n)
|
||
}
|
||
}
|
||
|
||
// Dispatch launches a bounded background goroutine for job id. run executes
|
||
// once a semaphore slot is acquired; onAbort runs instead if the job is
|
||
// cancelled (user cancel or shutdown) while still waiting for a slot. Both
|
||
// receive a context derived from the application context that is cancelled by
|
||
// Cancel(id) or server shutdown.
|
||
func (r *Runner) Dispatch(id int64, run func(ctx context.Context), onAbort func(ctx context.Context)) {
|
||
runCtx, cancel := context.WithCancel(r.baseCtx)
|
||
r.mu.Lock()
|
||
r.cancels[id] = cancel
|
||
r.mu.Unlock()
|
||
|
||
r.wg.Add(1)
|
||
go func() {
|
||
defer r.wg.Done()
|
||
defer func() {
|
||
r.mu.Lock()
|
||
delete(r.cancels, id)
|
||
r.mu.Unlock()
|
||
cancel()
|
||
}()
|
||
|
||
// Heartbeat for the whole lifetime — crucially including while queued
|
||
// behind the semaphore — so the stale-job reaper never reaps a job that
|
||
// is alive but merely waiting for a slot (which would otherwise mark it
|
||
// failed and let it resurrect itself on acquire, or admit a duplicate).
|
||
stopHeartbeat := make(chan struct{})
|
||
defer close(stopHeartbeat)
|
||
go r.heartbeatLoop(runCtx, id, stopHeartbeat)
|
||
|
||
select {
|
||
case r.sem <- struct{}{}:
|
||
case <-runCtx.Done():
|
||
if onAbort != nil {
|
||
onAbort(context.WithoutCancel(runCtx))
|
||
}
|
||
return
|
||
}
|
||
defer func() { <-r.sem }()
|
||
|
||
run(runCtx)
|
||
}()
|
||
}
|
||
|
||
// Cancel cancels the in-flight goroutine for id, returning false when none is
|
||
// registered (job on another node, or never dispatched) so the caller can
|
||
// fall back to a best-effort terminal transition in the database.
|
||
func (r *Runner) Cancel(id int64) bool {
|
||
r.mu.Lock()
|
||
cancel := r.cancels[id]
|
||
r.mu.Unlock()
|
||
if cancel == nil {
|
||
return false
|
||
}
|
||
cancel()
|
||
return true
|
||
}
|
||
|
||
// heartbeatLoop keeps a job's heartbeat_at fresh until the job ends or the
|
||
// context is cancelled, so the stale-job reaper only ever reaps jobs orphaned
|
||
// by a crashed worker.
|
||
func (r *Runner) heartbeatLoop(ctx context.Context, jobID int64, stop <-chan struct{}) {
|
||
ticker := time.NewTicker(HeartbeatInterval)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-stop:
|
||
return
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
_ = r.store.Heartbeat(context.WithoutCancel(ctx), jobID)
|
||
}
|
||
}
|
||
}
|