feat(ai): shared AI core — metadata translation, Whisper ASR, per-profile language, on-view translation (#127)

* 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>
This commit is contained in:
Quick
2026-06-10 14:58:54 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent c7f65bf351
commit 39ba284c9d
71 changed files with 7388 additions and 815 deletions
@@ -0,0 +1,377 @@
# AI Services Core, Metadata Translation & Whisper ASR — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Design:** `docs/superpowers/specs/2026-06-10-ai-translation-and-asr-design.md` (read it first; this plan does not repeat the rationale).
**Goal:** Extract the subtitle-AI LLM plumbing into shared packages (`internal/ai/llm`, `internal/ai/translate`, `internal/ai/jobrunner`), add AI translation of overviews/taglines into the existing localization tables with field provenance, and add Whisper ASR subtitle generation (`transcribe`, `transcribe_translate`). Single PR.
**Architecture:** New `internal/ai/*` packages consumed by a refactored `internal/subtitles/ai` and a new `internal/metadata/translation` service. One new Goose migration (jobs table, provenance columns, library auto-translate flag). New `ai.*` settings keys with loader fallback to legacy `subtitle_ai.*` rows (encrypted values are GCM-bound to their key — never rename rows in SQL). Frontend: AI Services settings page, metadata-editor translate action, library toggle, player "generate from audio" mode.
**Tech stack:** Go (chi, pgx), PostgreSQL via Goose SQL migrations, React + TypeScript (TanStack Query), Vitest.
Commands assume the repository root is the cwd.
---
## Ordering and verification
Tasks 1–4 are a pure refactor and must leave subtitle translation behaviorally unchanged — run `go build ./... && go test ./internal/...` after each. Tasks 5–11 are backend features; 12–15 frontend; 16 final verification.
Before opening the MR: `make lint`, `cd web && pnpm run lint && pnpm run format:check`, `make verify-local-paths`.
---
## File structure
**Create**
- `internal/ai/llm/client.go`, `client_test.go`, `config.go` — shared OpenAI-compatible client (chat + transcription)
- `internal/ai/llm/transcribe.go` — multipart `/v1/audio/transcriptions`, `verbose_json` types
- `internal/ai/translate/translate.go`, `translate_test.go` — generic segment batch translator (package `aitranslate`)
- `internal/ai/jobrunner/runner.go`, `runner_test.go` — dispatch/semaphore/heartbeat/reaper/cancel registry
- `internal/metadata/translation/{job.go,pgrepo.go,service.go,prompt.go,service_test.go}`
- `internal/subtitles/ai/transcriber.go`, `transcriber_test.go` — ASR pipeline
- `internal/playback/audio_extract.go` — ffmpeg chunked audio extraction helper
- `internal/api/handlers/metadata_ai.go`
- `migrations/sql/<timestamp>_ai_metadata_translation_and_asr.sql` (via `make migrate-create`)
- `web/src/pages/admin-settings/AIServicesSettings.tsx`
**Modify**
- `internal/subtitles/ai/{client.go→deleted,translator.go,service.go,engine.go,job.go,config.go,pgrepo.go}`
- `internal/config/{config.go,db_loader.go}`; `internal/catalog/encrypted_settings_repo.go`
- `internal/catalog/{localization_repo.go,detail.go}`; `internal/models/media.go`
- `internal/metadata/service.go` (provider provenance + auto-enqueue hook)
- `internal/api/handlers/subtitle_ai.go`; router registration; `cmd/silo/main.go` wiring
- `web/src/pages/admin-settings/SubtitlesSettings.tsx`, `web/src/components/EditMetadataDialog.tsx`, `web/src/player/components/SubtitleTranslateModal.tsx`, library settings form, `web/src/api/` types/client, admin settings nav/route registration
---
## Task 1: `internal/ai/llm` — shared client
- [ ] **Step 1: Move the chat client.** Create `internal/ai/llm` (package `llm`). Move `Client`, `chat` (export as `Chat`), `chatMessage` (export as `Message`), retry helpers (`sleepCtx`, `rateLimitBackoff`, `truncate`) from `internal/subtitles/ai/client.go`. New `llm.Config`:
```go
type Config struct {
BaseURL string // chat endpoint, no trailing /v1
APIKey string
ChatModel string
ASRBaseURL string // empty = BaseURL
ASRAPIKey string // empty = APIKey
ASRModel string
MaxConcurrentJobs int
}
```
Log lines lose the "subtitle" wording (they are shared now).
- [ ] **Step 2: Add `Transcribe`.** In `transcribe.go`:
```go
type TranscribeRequest struct {
Filename string
Audio io.Reader
Language string // optional ISO-639-1 hint
Timeout time.Duration // per-request; sized to chunk length by the caller
}
type TranscriptionSegment struct{ Start, End float64; Text string }
type Transcription struct{ Language string; Segments []TranscriptionSegment }
func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transcription, error)
```
Multipart fields: `file`, `model` (=`ASRModel`), `response_format=verbose_json`, `temperature=0`, optional `language`. Reuse the same 429/5xx/transport retry-with-backoff loop as `Chat` (extract a shared `doWithRetry` rather than duplicating it). Empty `segments` in the response is an error (`transcription returned no segments`), not a silent fallback.
- [ ] **Step 3: Tests** (`client_test.go`, httptest): chat retries on 429 (honors `Retry-After`), 5xx, 200-with-error-object, empty choices; transcribe happy path (multipart fields present, segments parsed), missing-segments error, ASR base-url/key override falling back to chat values.
- [ ] **Step 4: Commit.** `refactor(ai): extract shared OpenAI-compatible LLM client with transcription support`
---
## Task 2: `internal/ai/translate` — generic segment translator
- [ ] **Step 1: Move the batch logic.** Package `aitranslate`. Move `buildIndexedJSON`, `extractJSONObject`, and the batch loop from `internal/subtitles/ai/translator.go`, generalized:
```go
type Segment struct{ ID, Text string }
type Request struct {
Segments []Segment
SystemPrompt string // caller-supplied; domain-specific
BatchSize int
ContextNeighbors int // preceding source segments sent untranslated
}
type ChatFn func(ctx context.Context, system, user string) (string, error)
func Translate(ctx context.Context, chat ChatFn, req Request,
onBatch func(batch []Segment, done, total int)) ([]Segment, error)
```
Wire protocol unchanged: 1-based indexed JSON per batch, same-keys response, malformed-response retries (`maxRetries = 2`), completeness check. `ChatFn` keeps the package free of an `llm` dependency and trivially testable.
- [ ] **Step 2: Tests:** batch splitting/boundaries, context neighbors, code-fence tolerance, omitted-key retry then failure, ctx cancellation between batches.
- [ ] **Step 3: Commit.** `refactor(ai): extract generic batched segment translator`
---
## Task 3: `internal/ai/jobrunner` — shared job lifecycle
- [ ] **Step 1: Extract the runner.** Move dispatch/heartbeat/reaper/cancel mechanics from `internal/subtitles/ai/service.go` (lines ~136–319) behind:
```go
type Store interface {
Heartbeat(ctx context.Context, id int64) error
ResetStaleJobs(ctx context.Context, before time.Time, message string) (int64, error)
MarkCancelled(ctx context.Context, id int64, message string) error
}
type Runner struct{ /* baseCtx, sem (shared), store, logger, cancels, wg */ }
func New(appCtx context.Context, sem chan struct{}, store Store, logger *slog.Logger) *Runner
func (r *Runner) Recover() // reap + background reaper loop
func (r *Runner) Dispatch(id int64, run func(ctx context.Context)) // semaphore + heartbeat + cancel registry
func (r *Runner) Cancel(id int64) bool // true if an in-flight goroutine was cancelled
```
Heartbeat-while-queued behavior (the comment block in `dispatch`) must be preserved verbatim — it is load-bearing for multi-instance safety. Constants (30 s heartbeat, 2 min stale, 1 min reaper) move here.
- [ ] **Step 2: Shared semaphore.** The semaphore is **constructed by the caller** (`cmd/silo`) at size `ai.max_concurrent_jobs` and passed to every `Runner`, so subtitle + metadata + ASR jobs share one bound.
- [ ] **Step 3: Tests:** dispatch bounded by a size-1 shared semaphore across two runners; cancel of queued job marks cancelled without running; reaper resets a stale heartbeat row (fake store).
- [ ] **Step 4: Commit.** `refactor(ai): extract shared job lifecycle runner`
---
## Task 4: Refactor `internal/subtitles/ai` onto the shared core
- [ ] **Step 1:** Delete `client.go`; `LLMTranslator` becomes an adapter: cues → `aitranslate.Segment` (ID = 1-based index, Text = joined lines), subtitle system prompt stays here, `splitCueLines` maps back. `Service` keeps job semantics but delegates lifecycle to `jobrunner.Runner`; `pgrepo` satisfies `jobrunner.Store`.
- [ ] **Step 2:** Update `cmd/silo/main.go` wiring (build `llm.Client` + shared semaphore once; pass into the subtitle service).
- [ ] **Step 3:** `go build ./... && go test ./internal/...` — subtitle translation behavior unchanged (same prompts, same batching, same job rows).
- [ ] **Step 4: Commit.** `refactor(subtitles): consume shared AI core`
---
## Task 5: Migration
- [ ] **Step 1:** `make migrate-create NAME=ai_metadata_translation_and_asr`, then fill the generated file:
```sql
-- +goose Up
-- +goose StatementBegin
CREATE TABLE public.metadata_translation_jobs (
id bigserial PRIMARY KEY,
target_kind text NOT NULL, -- 'item' | 'season' | 'episode'
content_id text NOT NULL,
include_children boolean NOT NULL DEFAULT true,
source_language text NOT NULL DEFAULT '',
target_language text NOT NULL,
engine text NOT NULL DEFAULT 'openai',
model text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'pending', -- pending|running|completed|failed|cancelled
progress double precision NOT NULL DEFAULT 0,
progress_message text NOT NULL DEFAULT '',
fields_done integer NOT NULL DEFAULT 0,
fields_total integer NOT NULL DEFAULT 0,
force boolean NOT NULL DEFAULT false,
error_message text NOT NULL DEFAULT '',
idempotency_key text NOT NULL,
requested_by integer,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
heartbeat_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX metadata_translation_jobs_active_idempotency_idx
ON public.metadata_translation_jobs (idempotency_key)
WHERE status IN ('pending', 'running');
CREATE INDEX metadata_translation_jobs_content_idx
ON public.metadata_translation_jobs (content_id, created_at DESC);
CREATE INDEX metadata_translation_jobs_status_idx
ON public.metadata_translation_jobs (status) WHERE status IN ('pending', 'running');
-- Field provenance: 'provider' | 'ai' | 'manual'. manual > provider > ai.
ALTER TABLE media_item_localizations
ADD COLUMN overview_source text NOT NULL DEFAULT 'provider'
CHECK (overview_source IN ('provider', 'ai', 'manual')),
ADD COLUMN tagline_source text NOT NULL DEFAULT 'provider'
CHECK (tagline_source IN ('provider', 'ai', 'manual'));
ALTER TABLE season_localizations
ADD COLUMN overview_source text NOT NULL DEFAULT 'provider'
CHECK (overview_source IN ('provider', 'ai', 'manual'));
ALTER TABLE episode_localizations
ADD COLUMN overview_source text NOT NULL DEFAULT 'provider'
CHECK (overview_source IN ('provider', 'ai', 'manual'));
ALTER TABLE media_folders
ADD COLUMN auto_translate_metadata boolean NOT NULL DEFAULT false;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE media_folders DROP COLUMN auto_translate_metadata;
ALTER TABLE episode_localizations DROP COLUMN overview_source;
ALTER TABLE season_localizations DROP COLUMN overview_source;
ALTER TABLE media_item_localizations DROP COLUMN tagline_source, DROP COLUMN overview_source;
DROP TABLE IF EXISTS public.metadata_translation_jobs;
-- +goose StatementEnd
```
- [ ] **Step 2:** `make migrate-up` against the local stack (`docker compose up -d postgres redis`), then `make migrate-status`.
- [ ] **Step 3: Commit.** `feat(metadata): migration for translation jobs, localization provenance, library auto-translate`
---
## Task 6: Settings & config
- [ ] **Step 1: Config structs** (`internal/config/config.go`): add `AIConfig` (fields mirroring `llm.Config`); slim `SubtitleAIConfig` to `Enabled`, `TranscribeEnabled`, `BatchSize`, `ContextNeighbors`; add `MetadataAIConfig{Enabled bool}`.
- [ ] **Step 2: Loader** (`internal/config/db_loader.go`): load `ai.*` with legacy fallback, following the `recommendations.embedding_auth_token` precedent at line ~409:
```go
cfg.AI.BaseURL = stringOr(m, "ai.base_url", stringOr(m, "subtitle_ai.base_url", "https://api.openai.com"))
cfg.AI.APIKey = stringOr(m, "ai.api_key", stringOr(m, "subtitle_ai.api_key", ""))
cfg.AI.ChatModel = stringOr(m, "ai.chat_model", stringOr(m, "subtitle_ai.chat_model", "gpt-4o-mini"))
// ai.max_concurrent_jobs ← subtitle_ai.max_concurrent_jobs ← 2
// ai.asr_model (default "whisper-1"), ai.asr_base_url, ai.asr_api_key (default "")
// subtitle_ai.transcribe_enabled, metadata_ai.enabled (default false)
```
**Never** rename the legacy rows in SQL — encrypted values are GCM-bound to their setting key (`internal/catalog/encrypted_settings_repo.go`).
- [ ] **Step 3:** Add `ai.api_key`, `ai.asr_api_key` to `sensitiveSettingKeys`.
- [ ] **Step 4:** Readiness helpers: subtitle translate = `SubtitleAI.Enabled && AI chat ready`; ASR = `SubtitleAI.TranscribeEnabled && AI.ASRModel != "" && (asr or chat base URL set)`; metadata = `MetadataAI.Enabled && AI chat ready`.
- [ ] **Step 5: Commit.** `feat(config): shared ai.* settings with legacy subtitle_ai fallback`
---
## Task 7: Provenance-aware localization writes
- [ ] **Step 1: Models** (`internal/models/media.go`): add `OverviewSource`/`TaglineSource` to `MediaItemLocalization`, `OverviewSource` to `SeasonLocalization`/`EpisodeLocalization`.
- [ ] **Step 2: Provider upsert rules** (`internal/catalog/localization_repo.go`): rewrite the three `Upsert` statements so, per AI-writable field:
- existing source `manual` → keep existing value and source;
- incoming value empty → keep existing value and source;
- otherwise → take incoming value, set source `provider`.
Single-statement `ON CONFLICT … DO UPDATE` with `CASE` (no read-modify-write). Pattern for one field:
```sql
overview = CASE
WHEN media_item_localizations.overview_source = 'manual' THEN media_item_localizations.overview
WHEN EXCLUDED.overview = '' THEN media_item_localizations.overview
ELSE EXCLUDED.overview END,
overview_source = CASE
WHEN media_item_localizations.overview_source = 'manual' THEN media_item_localizations.overview_source
WHEN EXCLUDED.overview = '' THEN media_item_localizations.overview_source
ELSE 'provider' END
```
- [ ] **Step 3: AI upsert methods**: `UpsertAITranslation(ctx, contentID, language string, overview, tagline *string, force bool)` per repo (nil pointer = field not part of this write). Insert path populates only the translated fields (other text columns empty). Update path per field: write when existing source is `ai`, existing value is empty, or `force` — never when `manual`; `force` may overwrite `provider` (the admin asked). Sets source `ai`.
- [ ] **Step 4: Serving hardening** (`internal/catalog/detail.go`): verify `LocalizeItemModel` / `LocalizeSeasonModel` / `LocalizeEpisodeModel` only override base fields with **non-empty** localization values (AI rows carry empty titles). Fix any field that clobbers; add a regression test.
- [ ] **Step 5: Tests** for the upsert matrix (provider-over-ai, ai-skips-provider-unless-force, manual-untouchable, empty-never-blanks), following the existing repo/service test patterns in `internal/catalog`.
- [ ] **Step 6: Commit.** `feat(catalog): field provenance for localizations with provider/ai/manual precedence`
---
## Task 8: `internal/metadata/translation` service
- [ ] **Step 1: Job model + repo** (`job.go`, `pgrepo.go`): mirror `internal/subtitles/ai/{job.go,pgrepo.go}` shapes against `metadata_translation_jobs`; satisfy `jobrunner.Store`. Idempotency key = SHA-256 of `content_id|target_kind|target_language|model`.
- [ ] **Step 2: Service** (`service.go`): `Enqueue`, `GetJob`, `ListJobs(contentID)`, `Cancel`, `Recover`, using `jobrunner.Runner` + the shared semaphore. The run loop:
1. Expand targets: `item` → item overview+tagline; series item with `include_children` → + every season overview + every episode overview; `season` (+children → its episodes); `episode` → its overview. Source text comes from the **base** rows (default metadata language); `source_language` recorded from the item's `default_metadata_language`.
2. Skip-if-filled: drop any field whose target-language localization value is already non-empty, unless `force`. All skipped → complete immediately ("Nothing to translate").
3. Build `aitranslate.Segment`s (IDs `item:overview`, `item:tagline`, `season:<content_id>:overview`, `episode:<content_id>:overview`), translate with `prompt.go`'s system prompt (names the title/year, "translate media catalog descriptions", preserve proper nouns/character names/tone, no added information), `BatchSize` = package constant `metadataBatchSize = 10`, `ContextNeighbors = 0`.
4. Per batch: provenance-aware AI upserts (Task 7) + `fields_done`/progress update. Persisting per batch means a cancelled job keeps completed fields.
- [ ] **Step 3: Tests** (fake repos + fake `ChatFn`): series expansion counts, skip-if-filled short-circuit (zero chat calls), force overwrites `ai`+`provider` but never `manual`, per-batch persistence on cancellation mid-job.
- [ ] **Step 4: Commit.** `feat(metadata): AI translation service for overviews and taglines`
---
## Task 9: Ingestion hook + library flag
- [ ] **Step 1:** Switch the provider localization writes in `internal/metadata/service.go` (item ~line 1476, season ~2921, episode ~3080) to the provenance-aware upserts — no other behavior change.
- [ ] **Step 2:** Thread `auto_translate_metadata` through the `MediaFolder` model, folder repo scan/update, and the folder settings API payload.
- [ ] **Step 3: Auto-enqueue hook.** After an item's refresh persists (single point at the end of the item flow — not per season/episode), when: folder flag set, folder `metadata_language` non-empty and ≠ item `default_metadata_language`, metadata AI ready, and the item's target-language localization is missing overview (or tagline) → `Enqueue` a non-force `item` job with `include_children=true`. Fire-and-forget (log on error); the active-idempotency index plus skip-if-filled make repeat refreshes free.
- [ ] **Step 4: Commit.** `feat(metadata): per-library auto-translate fallback on refresh`
---
## Task 10: Metadata AI API
- [ ] **Step 1:** `internal/api/handlers/metadata_ai.go`, mirroring `subtitle_ai.go` shapes:
- `GET /api/v1/metadata/ai/status` → `{enabled}`
- `POST /api/v1/metadata/ai/translate` `{content_id, target_kind, target_language, include_children, force}` → `{job}` (joins an in-flight duplicate, same as subtitles)
- `GET /api/v1/metadata/ai/jobs/{job_id}`, `POST /api/v1/metadata/ai/jobs/{job_id}/cancel`, `GET /api/v1/metadata/ai/jobs?content_id=…`
- [ ] **Step 2:** Gate with the same permission as metadata editing (metadata curation permission; see `internal/catalog/update.go` callers). Register routes; wire the service in `cmd/silo/main.go`.
- [ ] **Step 3:** Handler tests following `internal/api/handlers` conventions (validation: bad kind, missing language, disabled engine → 400/503 mapping).
- [ ] **Step 4: Commit.** `feat(api): metadata AI translation endpoints`
---
## Task 11: Whisper ASR
- [ ] **Step 1: Audio extraction** (`internal/playback/audio_extract.go`), alongside the existing subtitle extraction helpers:
```go
// ExtractAudioChunks extracts one audio track to 16 kHz mono WAV chunks in dir.
// Returns ordered chunk paths; chunkSeconds = 600 in production.
func ExtractAudioChunks(ctx context.Context, filePath string, audioTrackIndex int,
dir, ffmpegPath string, chunkSeconds int) ([]string, error)
```
Single ffmpeg pass: `-vn -map 0:a:<idx> -ac 1 -ar 16000 -c:a pcm_s16le -f segment -segment_time <n> <dir>/chunk%05d.wav`.
- [ ] **Step 2: Transcriber** (`internal/subtitles/ai/transcriber.go`):
```go
type Transcriber interface {
Transcribe(ctx context.Context, req TranscribeJobRequest,
onChunk func(cues []SubtitleCue, done, total int)) ([]SubtitleCue, string /*detected lang*/, error)
}
```
`WhisperTranscriber` implementation: temp dir (`os.MkdirTemp`, removed on all exit paths) → `ExtractAudioChunks` → per chunk `llm.Client.Transcribe` (per-request timeout ∝ chunk duration; pass the job's language hint) → offset segment times by `chunkIndex*chunkSeconds` → cues (one per segment, wrapped to ≤2 lines ×~42 chars via a `wrapCueText` helper; whitespace-only segments dropped). Chunk processing order starts at the chunk containing `StartPosition`, then wraps (mirrors `reorderFromPosition` semantics).
- [ ] **Step 3: Wire kinds into the service.** `Service.run` branches on `job.Kind`:
- `translate`: existing path.
- `transcribe`: resolve audio track (`source_index` = audio index, `-1` → default/first; reject files without audio), transcribe (progress 10–70 %, live cue streaming via the existing notifier callbacks), store SRT as provider `transcribed`, language = hint or detected, release name `"<Language> (AI transcribed)"`, notify `SubtitleReady`.
- `transcribe_translate`: `transcribe`, store the transcript track, then run the existing `Translator` on the cues (70–95 %), store provider `translated`, `result_subtitle_id` = translated track.
Enqueue validation per kind (ASR requires `TranscribeEnabled`; idempotency model component = `asr_model` or `asr_model+chat_model` for the chained kind).
- [ ] **Step 4:** API surface: `kind` field (optional, default `translate`) on the existing enqueue request in `subtitle_ai.go`; `transcribe_enabled` on the status response.
- [ ] **Step 5: Tests** (`transcriber_test.go`, fake client): timestamp stitching across chunks, playhead-first chunk ordering, cue wrapping, empty-segments chunk → job error, temp-dir cleanup on failure (assert via `t.TempDir` layout).
- [ ] **Step 6: Commit.** `feat(subtitles): Whisper ASR transcribe and transcribe_translate jobs`
---
## Task 12: Frontend — AI Services settings page
- [ ] **Step 1:** `web/src/pages/admin-settings/AIServicesSettings.tsx`: connection card (`ai.base_url`, `ai.api_key` via the sensitive-key pattern already used in `SubtitlesSettings.tsx:264`, `ai.chat_model`, `ai.asr_model`, optional `ai.asr_base_url`/`ai.asr_api_key`, `ai.max_concurrent_jobs`) + features card (`subtitle_ai.enabled`, `subtitle_ai.transcribe_enabled`, `metadata_ai.enabled`, `subtitle_ai.batch_size`, `subtitle_ai.context_neighbors`). Reads show effective values (new key, falling back to the legacy key when unset — same fallback order as the loader); writes always target the new `ai.*` keys.
- [ ] **Step 2:** Remove the AI card from `SubtitlesSettings.tsx` (leave a link/hint to the new page); register the page in the admin settings nav + route following the existing page registrations.
- [ ] **Step 3: Commit.** `feat(web): AI services settings page`
---
## Task 13: Frontend — metadata translate action
- [ ] **Step 1:** API client functions + types for the Task 10 endpoints.
- [ ] **Step 2:** `EditMetadataDialog.tsx`: "Translate with AI" action (visible when `metadata_ai` status is enabled): target language (default = library `metadata_language`), `include_children` (series only), `force` checkbox; enqueue, poll the job every ~1.5 s, show `progress_message`/`fields_done`, invalidate the item detail query on completion.
- [ ] **Step 3: Commit.** `feat(web): translate descriptions from the metadata editor`
---
## Task 14: Frontend — library auto-translate toggle
- [ ] **Step 1:** Add the `auto_translate_metadata` switch to the library settings form next to the metadata-language field (helper text: "When metadata providers have no translation for this library's language, translate descriptions with AI"). Disabled state with hint when `metadata_ai` is off.
- [ ] **Step 2: Commit.** `feat(web): library auto-translate toggle`
---
## Task 15: Frontend — player "Generate from audio"
- [ ] **Step 1:** `SubtitleTranslateModal.tsx`: when `transcribe_enabled`, add a source option "Generate from audio (AI)" listing the file's audio tracks; submit with `kind: "transcribe"` (or `transcribe_translate` when the chosen output language differs from the audio language). Surface it prominently when no text subtitle source exists (today's dead end for bitmap-only files).
- [ ] **Step 2: Commit.** `feat(web): generate subtitles from audio in the player`
---
## Task 16: Verification
- [ ] `go build ./... && go test ./...`
- [ ] `make lint`
- [ ] `cd web && pnpm run lint && pnpm run format:check`
- [ ] `make verify-local-paths`
- [ ] Manual smoke against the local stack: enqueue a metadata translation for a series (verify localization rows + provenance values), re-run a refresh (verify provider values overwrite `ai` rows and `manual` simulation survives), run a `transcribe` job on a short file against a local Whisper-compatible server.
- [ ] MR description: problem, approach, link to the design doc, risks (Whisper endpoint variance, chunk-boundary artifacts), AI-use disclosure, screenshots of the new settings page / editor action / player mode.
@@ -0,0 +1,311 @@
# AI services: shared core, metadata translation, Whisper ASR — design
**Date:** 2026-06-10
**Status:** Draft, pending review
**Scope:** Extract the AI subtitle translation engine into a shared AI core, add AI translation of movie/series/season/episode descriptions into the existing localization tables, and add Whisper ASR subtitle generation (`transcribe`, `transcribe_translate`). One PR.
Commands assume the repository root is the cwd.
## Problem
`internal/subtitles/ai` ships an on-demand LLM subtitle translator. The LLM plumbing inside
it (OpenAI-compatible chat client with retry/backoff, batched indexed-JSON translation,
job lifecycle with idempotency/heartbeat/reaper/bounded concurrency) is generic, but it is
buried in a subtitle-specific package, so nothing else can use it.
Two features want exactly that plumbing:
1. **Metadata translation.** Libraries whose `metadata_language` the providers cannot fully
serve end up with overviews/taglines in the item's default language (usually English).
The localization tables (`media_item_localizations`, `season_localizations`,
`episode_localizations`) and the serving path (`LocalizeItemModel` and friends in
`internal/catalog/detail.go`) already exist — the gap is producing rows for languages the
providers don't have.
2. **Whisper ASR.** `subtitle_ai_jobs.kind` already reserves `transcribe` and
`transcribe_translate`; media with no usable text subtitle track (foreign audio with only
bitmap subs, or no subs at all) currently has no AI path to a subtitle track.
## Goals
- One shared AI core: a single OpenAI-compatible endpoint configuration (chat + audio
transcription), one retry/backoff implementation, one generic batched text translator,
one job-lifecycle runner — consumed by subtitle translation, metadata translation, and ASR.
- AI-translated overviews and taglines stored in the **existing** localization tables, so
web, Android, Apple, and jellycompat clients get them through the current serving path
with **zero client changes**.
- Provenance on AI-writable localization fields so refreshes never regress data quality:
`manual` beats `provider` beats `ai`. A later provider localization overwrites an AI
translation; the reverse never happens.
- Two metadata-translation triggers: a manual admin action, and an opt-in per-library
fallback ("provider had no localization for the library language → AI-translate") wired
into metadata ingestion.
- ASR jobs that produce ordinary `downloaded_subtitles` rows (provider `transcribed`),
with `transcribe_translate` chaining the existing LLM translator for a target-language
track; live cue streaming to the requesting playback session, same as translation today.
- Total AI load on the configured endpoint bounded by **one** shared semaphore.
## Non-goals
- **Episode/movie/series title translation.** Titles often have official localized forms
that are not literal translations; provider data only.
- **Collection names or descriptions.** Deferred: collections have no localization table
yet. The metadata translation service is written against content-ID + field-list inputs,
so a `collection_localizations` table can be added in a follow-up without reshaping it.
- **Genre translation.** If ever wanted, that is a static mapping, not an LLM call.
- **Per-profile metadata language.** Presentation language stays library-scoped
(`media_folders.metadata_language`); changing that is an orthogonal feature.
- **A localization editor UI.** The `manual` provenance value is honored by the write
rules but no UI sets it yet (the existing metadata editor edits base fields, not
localizations).
- **Whisper word-level timing, diarization, VAD-aligned chunking.** v1 uses fixed-length
chunks; boundary-word artifacts are an accepted limitation noted for follow-up.
## Architecture
### Package layout
```
internal/ai/llm Client: OpenAI-compatible /v1/chat/completions (moved from
internal/subtitles/ai/client.go) + new /v1/audio/transcriptions.
Shared retry/backoff/429 handling. Config for the endpoint(s).
internal/ai/translate Generic batched text translation: []Segment{ID, Text} in,
translated segments out. Indexed-JSON protocol, context
neighbors, malformed-response retries — moved from
internal/subtitles/ai/translator.go and de-subtitled.
internal/ai/jobrunner Generic job lifecycle: bounded dispatch off a shared semaphore,
heartbeat loop, stale-job reaper, cancel registry — extracted from
internal/subtitles/ai/service.go behind a small repo interface.
internal/subtitles/ai Stays: subtitle job semantics. Translate path becomes a thin
adapter (cues ↔ segments); gains the transcribe paths.
internal/metadata/translation
New: metadata translation job service + repo + provenance-aware
writes into the localization repos.
```
Package names: `llm`, `aitranslate`, `jobrunner` (import paths above; `aitranslate` avoids
a name collision when imported from package `ai`).
### Shared semaphore
`cmd/silo` builds one `chan struct{}` of size `ai.max_concurrent_jobs` and hands it to both
job services via `jobrunner`. Subtitle, metadata, and ASR jobs all draw from it, so the
operator's endpoint sees a bounded number of concurrent jobs regardless of job mix.
(An ASR job holds one slot for its whole pipeline: extract → transcribe → translate.)
## Settings
Connection settings move to a shared `ai.*` namespace. Because encrypted values are
GCM-bound to their setting key (see `internal/catalog/encrypted_settings_repo.go`), the
legacy rows are **not renamed**; the loader falls back to them, following the precedent of
`recommendations.embedding_auth_token` → `recommendations.openai_api_key` in
`internal/config/db_loader.go`.
| Key | Default | Fallback | Notes |
| --- | --- | --- | --- |
| `ai.base_url` | `https://api.openai.com` | `subtitle_ai.base_url` | |
| `ai.api_key` | `""` | `subtitle_ai.api_key` | sensitive |
| `ai.chat_model` | `gpt-4o-mini` | `subtitle_ai.chat_model` | |
| `ai.max_concurrent_jobs` | `2` | `subtitle_ai.max_concurrent_jobs` | shared semaphore size |
| `ai.asr_model` | `whisper-1` | — | |
| `ai.asr_base_url` | `""` (= `ai.base_url`) | — | separate local Whisper server |
| `ai.asr_api_key` | `""` (= `ai.api_key`) | — | sensitive |
| `subtitle_ai.enabled` | `false` | — | existing; subtitle translation toggle |
| `subtitle_ai.transcribe_enabled` | `false` | — | new; ASR toggle |
| `subtitle_ai.batch_size` | `40` | — | existing; cues per chat request |
| `subtitle_ai.context_neighbors` | `2` | — | existing |
| `metadata_ai.enabled` | `false` | — | new; metadata translation toggle |
`ai.api_key` and `ai.asr_api_key` join `sensitiveSettingKeys`. The admin UI reads through
the same fallback (effective values come from the loaded config) and writes the new keys.
Config structs: `AIConfig` (connection + concurrency), `SubtitleAIConfig` (slims down to
toggles + batch tuning), new `MetadataAIConfig`. Readiness: subtitle translate requires
`subtitle_ai.enabled` + chat config; ASR requires `subtitle_ai.transcribe_enabled` + ASR
config; metadata requires `metadata_ai.enabled` + chat config.
## Metadata translation
### What gets translated
| Target | Fields |
| --- | --- |
| movie / series (`media_item_localizations`) | `overview`, `tagline` |
| season (`season_localizations`) | `overview` |
| episode (`episode_localizations`) | `overview` |
Titles, sort titles, and artwork columns in those rows are never written by AI.
### Provenance
New columns (default `'provider'`, check-constrained to `provider|ai|manual`):
- `media_item_localizations.overview_source`, `media_item_localizations.tagline_source`
- `season_localizations.overview_source`
- `episode_localizations.overview_source`
Write rules, enforced in the localization repos (single-statement upserts with CASE):
| Writer | May overwrite | Sets source | Notes |
| --- | --- | --- | --- |
| provider ingestion | `provider`, `ai` | `provider` | a non-empty provider value always replaces an AI value; an **empty** provider value never blanks an existing one |
| AI translation | `ai`, empty | `ai` | with `force`, may also overwrite `provider` (admin explicitly re-translating) — never `manual` |
| manual (future editor) | anything | `manual` | |
Serving hardening: `LocalizeItemModel` / `LocalizeSeasonModel` / `LocalizeEpisodeModel`
must only override base fields with **non-empty** localization values, since AI-created
rows legitimately carry empty titles. (Verify current behavior; fix if it clobbers.)
### Job model
New table `metadata_translation_jobs`, mirroring `subtitle_ai_jobs` conventions
(status enum, progress, idempotency partial-unique index, heartbeat):
- `target_kind` (`item` | `season` | `episode`), `content_id`, `include_children`
- `target_language`, `source_language` (default language of the base row)
- `engine`, `model`, `status`, `progress`, `progress_message`, `fields_done`, `fields_total`
- `error_message`, `idempotency_key`, `requested_by`, `force`, timestamps, `heartbeat_at`
A job for a series item with `include_children` expands to: item overview + tagline, every
season overview, every episode overview. All fields become segments
(`item:overview`, `season:<content_id>:overview`, `episode:<content_id>:overview`, …) fed
to the generic translator in batches (metadata batch size constant, smaller than the
subtitle one — paragraphs, not cue lines), so episode descriptions share terminology in one
prompt context. The system prompt names the series/movie and year for grounding.
Skip logic in the run loop (not at enqueue): a field whose localization value is already
non-empty is skipped unless `force` — so the auto trigger costs zero LLM calls on repeat
refreshes. If every field is skipped the job completes immediately.
Idempotency key: `content_id | target_kind | target_language | model`, active-only partial
unique index (completed/failed rows never block a retry).
### Triggers
1. **Manual.** Admin action on the item detail metadata editor: pick target language
(defaults to the library's `metadata_language`), `include_children`, `force`. Gated by
the same permission as metadata editing.
2. **Auto fallback.** New `media_folders.auto_translate_metadata boolean NOT NULL DEFAULT
false`. At the end of a metadata refresh for an item (after provider localizations are
persisted in `internal/metadata/service.go`), if the folder has the flag, has a
`metadata_language`, that language differs from the item's default metadata language,
and the localization row for it is missing a translated field — enqueue a non-force job
for the item (children included for series). Enqueue is fire-and-forget; refresh latency
is unaffected.
### API
Admin-gated, mirroring the subtitle AI handler shapes:
- `GET /api/v1/metadata/ai/status`
- `POST /api/v1/metadata/ai/translate` `{content_id, target_kind, target_language, include_children, force}`
- `GET /api/v1/metadata/ai/jobs/{job_id}` / `POST …/cancel`
- `GET /api/v1/metadata/ai/jobs?content_id=…`
Progress UI polls the job endpoint (jobs are short; no websocket events in v1).
## Whisper ASR
### Kinds
`subtitle_ai_jobs.kind` gains its reserved values:
- `transcribe` — audio track → subtitle track in the spoken language.
- `transcribe_translate` — `transcribe`, then the existing LLM translation chained to
`target_language`. Both the transcript track and the translated track are stored (the
transcript is a cache: a second target language skips ASR entirely because the transcript
is now a selectable text source for a plain `translate` job). `result_subtitle_id`
points at the translated track.
For ASR kinds, `source_index` holds the **audio** track index (`-1` = default/first audio
track); `source_language` is an optional hint passed to Whisper, else taken from the
detected `verbose_json.language`.
### Pipeline
1. Resolve the audio track from `media_files`; reject files with no audio.
2. Extract once with ffmpeg into fixed chunks in a job temp dir
(`-vn -map 0:a:<idx> -ac 1 -ar 16000 -c:a pcm_s16le -f segment -segment_time 600`):
16 kHz mono WAV, 10-minute chunks (~19 MB, under typical 25 MB API limits). Temp dir
removed on every exit path.
3. Per chunk: `POST /v1/audio/transcriptions` (multipart: file, model,
`response_format=verbose_json`, optional `language`, `temperature=0`) via the shared
`llm.Client` with the shared retry/backoff. Segment timestamps are offset by the chunk
start and merged.
4. Cue building: one cue per Whisper segment; text wrapped to at most two lines
(~42 chars/line). Empty/whitespace segments dropped.
5. Store as `downloaded_subtitles` (provider `transcribed`, SRT, release name
`"<Language> (AI transcribed)"`), notify `SubtitleReady`.
6. `transcribe_translate`: feed the cues to the existing `Translator`; store the translated
track (provider `translated`) and complete.
Chunks are processed starting from the chunk containing the viewer's playhead, then
wrapping — same UX as translation — and cues stream live to the requesting session through
the existing notifier. Progress bands: extract 0–10 %, transcribe 10–70 % (per chunk),
translate 70–95 %.
Known v1 limitation: fixed chunk boundaries can clip a word at the seam; acceptable, noted
for a silence-aligned follow-up.
### API / UI
The existing enqueue endpoint `POST /api/v1/subtitles/ai/translate` gains an optional
`kind` (default `translate`; backward compatible). `GET /api/v1/subtitles/ai/status` gains
`transcribe_enabled`. The player's translate modal gains a "Generate from audio" mode that
lists audio tracks and a target language — the natural path when the only subtitle tracks
are bitmap (today's hard error) or none exist.
## Frontend
- **AI Services settings page** (`web/src/pages/admin-settings/`): connection card
(base URL, API key, chat model, ASR model, optional ASR base URL/key), features card
(subtitle translation, ASR, metadata translation toggles; concurrency; batch tuning).
The AI card currently inside `SubtitlesSettings.tsx` moves here; subtitle settings keep
the download-provider config.
- **Metadata editor**: "Translate with AI" action in `EditMetadataDialog.tsx` (language,
include children, force), with job-poll progress and query invalidation on completion.
- **Library settings**: `auto_translate_metadata` toggle next to the metadata-language
field.
- **Player**: "Generate from audio" mode in `SubtitleTranslateModal.tsx`.
## Client (Android / Apple) impact
None required. Translated overviews/taglines arrive through the existing localized detail
responses; ASR/translated tracks arrive as ordinary downloaded subtitles. Optional
follow-ups in the client repos: surface "Generate from audio" in their subtitle menus
(the jobs API is shared) and the library auto-translate toggle in their admin surfaces.
## Reliability
- All jobs: idempotency dedup of in-flight work, heartbeat + stale reaper (crash-safe,
multi-instance safe), terminal-state handling on cancel/shutdown — one implementation in
`jobrunner`, no copy-paste between the two services.
- Provider-wins/manual-wins provenance is enforced in SQL (single-statement upserts), not
in racy read-modify-write Go code.
- The shared semaphore bounds total endpoint load; ASR temp space is cleaned on success,
failure, cancel, and shutdown.
- Auto-translate re-runs are free: skip-if-filled happens before any LLM call.
## Migrations
One Goose migration (`make migrate-create NAME=ai_metadata_translation_and_asr`):
1. `metadata_translation_jobs` table + active-idempotency / content / status indexes.
2. Provenance columns on the three localization tables (+ check constraints).
3. `media_folders.auto_translate_metadata`.
Down: drop the table, columns, and constraint.
## Risks / open questions
- **Whisper-endpoint compatibility.** OpenAI, Groq, faster-whisper-server/speaches all
speak `verbose_json`, but segment quality varies; the cue builder must tolerate missing
segment arrays (fall back to one cue per chunk worth of `text` is *not* acceptable —
fail the job with a clear message instead).
- **`LocalizeItemModel` empty-field semantics** must be verified before AI rows (empty
titles) ship; this is called out as an explicit plan task.
- **Long-running ASR on slow local Whisper servers** can exceed the 10-minute HTTP client
timeout per chunk; the transcription call uses a per-request timeout sized for chunk
duration rather than the client default.
- **Settings sprawl.** The `ai.*`/legacy fallback leaves old `subtitle_ai.*` connection
rows in place indefinitely; harmless, but a future settings-GC could prune them once the
new keys are written.
@@ -0,0 +1,78 @@
# On-view description translation + per-profile metadata language — design
**Date:** 2026-06-10
**Status:** Approved direction (per-profile preference; auto with button fallback; any user behind an opt-in setting)
**Builds on:** `docs/superpowers/specs/2026-06-10-ai-translation-and-asr-design.md`
Commands assume the repository root is the cwd.
## Problem
Metadata translation currently triggers from the admin editor or the per-library
refresh fallback, always targeting the library's metadata language. A viewer whose
language differs from the library's (or whose library was never auto-translated) has
no way to get descriptions in their language, and there is no per-person language
preference at all — presentation language is library-scoped.
## Design
### Per-profile preferred metadata language
- New column `user_profiles.preferred_metadata_language text NOT NULL DEFAULT ''`
(empty = inherit the library's metadata language). Threaded through the profile
model/repo/API exactly like the existing `language` (UI language) field, plus a
picker in the profile settings UI with an "Inherit library default" option.
- `access.Scope` gains `PreferredMetadataLanguage`, populated when the access
resolver loads the profile, so every request already carries it.
- `catalog.AccessFilter` gains `ProfilePreferredLanguage`, and presentation language
resolution becomes: explicit `PresentationLanguage` (request param) →
**profile preference** → library `metadata_language`. Applied wherever the scope
is available (native API and jellycompat), so all clients get profile-localized
metadata server-side.
### "Not in your language" signal
`ItemDetail` gains `pending_translation_language` (omitted when empty): set when the
resolved presentation language differs from the item's default metadata language,
the base overview is non-empty, and no localization overview exists for that
language. It is pure data — clients combine it with the AI feature status to decide
what to render.
### On-view trigger
- New setting `metadata_ai.on_view`: `off` (default) | `button` | `auto`, surfaced
on `GET /api/v1/metadata/ai/status` as `on_view`.
- New authenticated endpoint `POST /api/v1/items/{id}/translate-description`
`{target_language}` — any profile, gated by `metadata_ai` readiness +
`on_view != off`, item access enforced with the same `EnsureAccessible` check the
watched-state endpoints use. The client echoes back the language the server
reported in `pending_translation_language`.
- The endpoint goes through a new `Service.RequestOnView`, which adds one guard on
top of the existing pipeline: if the latest job for (content, language) failed
within a cooldown window, return it instead of enqueuing — a broken endpoint must
not be retried on every page view. Existing guarantees do the rest: in-flight
idempotency collapses concurrent viewers onto one job, skip-if-filled makes
repeats free, and the shared semaphore caps endpoint load.
### Web UX
On the detail page, when `pending_translation_language` is set:
- `on_view=auto`: fire the request on view (once per item), pulse-animate the
description while waiting, and poll the detail query until the flag clears (the
job's first batch translates the item's own overview, so this lands in seconds
even when a series job continues episodes in the background). Time out after ~45 s
and show the original text — no error states in the viewer's face.
- `on_view=button`: a small "Translate" chip near the description triggers the same
flow on click.
No job-status endpoint for viewers: completion is observed as the flag clearing on
refetch; failures surface only as the shimmer timing out (the cooldown stops
re-triggering).
## Non-goals
- Per-profile language in the Android/Apple UIs (server-side serving means they
already *receive* localized metadata; surfacing the trigger there is a client
follow-up using the same endpoint).
- Translating titles, or any admin-side behavior changes.
+1
View File
@@ -73,6 +73,7 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
scope.MaxContentRating = profile.MaxContentRating
scope.MaxPlaybackQuality = MinQuality(scope.MaxPlaybackQuality, NormalizePlaybackQuality(profile.MaxPlaybackQuality))
scope.PreferredMetadataLanguage = profile.PreferredMetadataLanguage
scope.AllowedLibraryIDs, scope.LibrariesRestricted = effectiveLibraries(user.LibraryIDs, profile)
scope.ProfileVerified = profile.PINHash == "" || input.SkipPINVerification
+5 -2
View File
@@ -9,8 +9,11 @@ type Scope struct {
LibrariesRestricted bool
MaxContentRating string
MaxPlaybackQuality string
PolicyRevision int64
ProfileVerified bool
// PreferredMetadataLanguage is the profile's metadata (presentation)
// language; "" inherits the library's metadata language.
PreferredMetadataLanguage string
PolicyRevision int64
ProfileVerified bool
}
// ResolveInput is the request input for resolving a viewer access scope.
+197
View File
@@ -0,0 +1,197 @@
// 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)
}
}
}
+158
View File
@@ -0,0 +1,158 @@
package jobrunner
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
)
type fakeStore struct {
mu sync.Mutex
heartbeats int
resets int
lastBefore time.Time
}
func (s *fakeStore) Heartbeat(context.Context, int64) error {
s.mu.Lock()
defer s.mu.Unlock()
s.heartbeats++
return nil
}
func (s *fakeStore) ResetStaleJobs(_ context.Context, before time.Time, _ string) (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.resets++
s.lastBefore = before
return 0, nil
}
func (s *fakeStore) snapshot() (int, time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
return s.resets, s.lastBefore
}
func TestRecoverReapsImmediatelyWithStaleCutoff(t *testing.T) {
store := &fakeStore{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
approxNow := time.Now()
New(ctx, nil, store, "test", nil).Recover()
resets, before := store.snapshot()
if resets < 1 {
t.Fatalf("Recover did not reap immediately: resets=%d", resets)
}
want := approxNow.Add(-StaleJobThreshold)
if diff := before.Sub(want); diff > 2*time.Second || diff < -2*time.Second {
t.Errorf("stale cutoff = %v, want ~%v", before, want)
}
}
// One shared semaphore bounds jobs across BOTH runners: with size 1, the
// second runner's job must not start until the first runner's job finishes.
func TestSharedSemaphoreBoundsAcrossRunners(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sem := NewSemaphore(1)
r1 := New(ctx, sem, &fakeStore{}, "one", nil)
r2 := New(ctx, sem, &fakeStore{}, "two", nil)
release := make(chan struct{})
aStarted := make(chan struct{})
var bStarted atomic.Bool
bDone := make(chan struct{})
r1.Dispatch(1, func(context.Context) {
close(aStarted)
<-release
}, nil)
<-aStarted
r2.Dispatch(2, func(context.Context) {
bStarted.Store(true)
close(bDone)
}, nil)
time.Sleep(50 * time.Millisecond)
if bStarted.Load() {
t.Fatal("job B ran while job A held the only slot")
}
close(release)
select {
case <-bDone:
case <-time.After(2 * time.Second):
t.Fatal("job B never ran after the slot freed")
}
}
// Cancelling a job that is queued behind the semaphore aborts it without
// running it, via the onAbort callback.
func TestCancelWhileQueuedCallsOnAbort(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sem := NewSemaphore(1)
r := New(ctx, sem, &fakeStore{}, "test", nil)
release := make(chan struct{})
defer close(release)
started := make(chan struct{})
r.Dispatch(1, func(context.Context) {
close(started)
<-release
}, nil)
<-started
var ran atomic.Bool
aborted := make(chan struct{})
r.Dispatch(2, func(context.Context) {
ran.Store(true)
}, func(context.Context) {
close(aborted)
})
if !r.Cancel(2) {
t.Fatal("Cancel(2) found no in-flight goroutine")
}
select {
case <-aborted:
case <-time.After(2 * time.Second):
t.Fatal("onAbort never ran")
}
if ran.Load() {
t.Fatal("cancelled queued job still ran")
}
}
func TestCancelUnknownJobReturnsFalse(t *testing.T) {
r := New(context.Background(), nil, &fakeStore{}, "test", nil)
if r.Cancel(99) {
t.Fatal("Cancel(99) = true for unknown job")
}
}
// The run context is cancelled by Cancel(id) so an in-flight job can stop.
func TestCancelRunningJobCancelsContext(t *testing.T) {
r := New(context.Background(), NewSemaphore(1), &fakeStore{}, "test", nil)
started := make(chan struct{})
stopped := make(chan struct{})
r.Dispatch(7, func(ctx context.Context) {
close(started)
<-ctx.Done()
close(stopped)
}, nil)
<-started
if !r.Cancel(7) {
t.Fatal("Cancel(7) found no in-flight goroutine")
}
select {
case <-stopped:
case <-time.After(2 * time.Second):
t.Fatal("running job did not observe cancellation")
}
}
+22
View File
@@ -0,0 +1,22 @@
package jobrunner
// Status is the lifecycle state shared by every AI job table.
type Status string
const (
StatusPending Status = "pending"
StatusRunning Status = "running"
StatusCompleted Status = "completed"
StatusFailed Status = "failed"
StatusCancelled Status = "cancelled"
)
// Terminal reports whether a status is final.
func (s Status) Terminal() bool {
switch s {
case StatusCompleted, StatusFailed, StatusCancelled:
return true
default:
return false
}
}
+297
View File
@@ -0,0 +1,297 @@
// Package llm provides the shared OpenAI-compatible API client used by every
// AI feature in Silo (subtitle translation, metadata translation, Whisper ASR).
// One endpoint configuration, one retry/backoff implementation; the operator
// can point it at OpenAI, Groq, a local Ollama/llama.cpp/faster-whisper
// server, etc.
package llm
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
)
// Config holds the connection settings for the shared OpenAI-compatible
// endpoints. The ASR fields are optional overrides for operators who run a
// separate Whisper-compatible server next to their chat endpoint; when empty,
// transcription uses the chat endpoint's base URL and key.
type Config struct {
BaseURL string // e.g. "https://api.openai.com" (no trailing /v1)
APIKey string // empty for keyless local servers
ChatModel string // chat-completions model used for translation
ASRBaseURL string // optional; empty = BaseURL
ASRAPIKey string // optional; empty = APIKey
ASRModel string // audio-transcription model, e.g. "whisper-1"
}
func (c Config) asrBaseURL() string {
if c.ASRBaseURL != "" {
return c.ASRBaseURL
}
return c.BaseURL
}
func (c Config) asrAPIKey() string {
if c.ASRAPIKey != "" {
return c.ASRAPIKey
}
return c.APIKey
}
// ChatConfigured reports whether chat completions are minimally configured.
func (c Config) ChatConfigured() bool { return c.BaseURL != "" && c.ChatModel != "" }
// ASRConfigured reports whether audio transcription is minimally configured.
func (c Config) ASRConfigured() bool { return c.asrBaseURL() != "" && c.ASRModel != "" }
// Client is a minimal OpenAI-compatible API client with shared retry/backoff
// conventions (429 with Retry-After, 5xx, transport errors, and gateway "200
// with embedded error object" responses are retried; other 4xx fail fast).
type Client struct {
cfg Config
// chatHTTP caps a single chat completion at 10 minutes. asrHTTP has no
// client-level timeout: a transcription upload's deadline is set per request
// (sized to the chunk duration), which a client timeout would silently cap.
chatHTTP *http.Client
asrHTTP *http.Client
}
// NewClient builds a client from the endpoint config.
func NewClient(cfg Config) *Client {
return &Client{
cfg: cfg,
chatHTTP: &http.Client{Timeout: 10 * time.Minute},
asrHTTP: &http.Client{},
}
}
// Message is one chat-completions message.
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatResponseFormat struct {
Type string `json:"type"`
}
type chatCompletionRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Temperature float32 `json:"temperature"`
ResponseFormat *chatResponseFormat `json:"response_format,omitempty"`
}
type chatCompletionResponse struct {
Choices []struct {
Message Message `json:"message"`
} `json:"choices"`
// Some OpenAI-compatible gateways (e.g. OpenRouter) return a 200 with an
// error object instead of an HTTP error status when an upstream provider
// fails. We surface and retry on it.
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
// Chat performs one chat completion and returns the first choice's content.
// When jsonObject is true it requests response_format=json_object; providers
// that ignore the field still work because the prompt itself demands JSON.
func (c *Client) Chat(ctx context.Context, messages []Message, jsonObject bool) (string, error) {
reqBody := chatCompletionRequest{
Model: c.cfg.ChatModel,
Messages: messages,
Temperature: 0.2,
}
if jsonObject {
reqBody.ResponseFormat = &chatResponseFormat{Type: "json_object"}
}
body, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal chat request: %w", err)
}
url := endpointURL(c.cfg.BaseURL, "chat/completions")
var content string
err = c.doWithRetry(ctx, c.chatHTTP, "chat API",
func() (*http.Request, error) {
httpReq, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if reqErr != nil {
return nil, fmt.Errorf("create request: %w", reqErr)
}
httpReq.Header.Set("Content-Type", "application/json")
if c.cfg.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
}
return httpReq, nil
},
func(respBody []byte) error {
var parsed chatCompletionResponse
if err := json.Unmarshal(respBody, &parsed); err != nil {
return fmt.Errorf("decode chat response: %w", err)
}
if parsed.Error != nil && parsed.Error.Message != "" {
// 200 with an upstream error object — transient on gateways.
return fmt.Errorf("chat API error: %s", parsed.Error.Message)
}
if len(parsed.Choices) == 0 || parsed.Choices[0].Message.Content == "" {
slog.Warn("AI chat returned no choices, retrying",
"model", c.cfg.ChatModel, "response_bytes", len(respBody))
return fmt.Errorf("chat API returned no choices")
}
content = parsed.Choices[0].Message.Content
return nil
})
if err != nil {
return "", err
}
return content, nil
}
// SystemUserChat performs one chat completion from a system + user prompt
// pair, requesting a JSON object response. Its signature matches
// aitranslate.ChatFn so a client method reference wires straight in.
func (c *Client) SystemUserChat(ctx context.Context, system, user string) (string, error) {
return c.Chat(ctx, []Message{
{Role: "system", Content: system},
{Role: "user", Content: user},
}, true)
}
// permanentError marks a parse failure that retrying cannot fix (e.g. an
// endpoint that structurally does not support the requested response format).
type permanentError struct{ err error }
func (e *permanentError) Error() string { return e.err.Error() }
func (e *permanentError) Unwrap() error { return e.err }
// doWithRetry runs the shared request/retry loop: build constructs a fresh
// request per attempt, parse consumes a 200 body. Transport errors, read
// errors, 429 (honoring Retry-After), 5xx, and retryable parse errors back
// off and retry; other 4xx and permanentError parse failures return at once.
func (c *Client) doWithRetry(ctx context.Context, httpClient *http.Client, label string,
build func() (*http.Request, error), parse func(body []byte) error) error {
const maxAttempts = 6
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
httpReq, err := build()
if err != nil {
return err
}
resp, doErr := httpClient.Do(httpReq)
if doErr != nil {
lastErr = fmt.Errorf("%s request failed: %w", label, doErr)
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
return waitErr
}
continue
}
respBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
// A truncated/failed read could otherwise be misparsed as a valid
// (empty) response; treat it as a retryable transport error.
lastErr = fmt.Errorf("read %s response: %w", label, readErr)
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
return waitErr
}
continue
}
switch {
case resp.StatusCode == http.StatusTooManyRequests:
wait := rateLimitBackoff(resp, attempt)
slog.Warn("rate limited by AI API, waiting", "api", label, "attempt", attempt+1, "wait", wait)
lastErr = fmt.Errorf("%s returned 429: %s", label, Truncate(string(respBody), 300))
if waitErr := sleepCtx(ctx, wait); waitErr != nil {
return waitErr
}
continue
case resp.StatusCode >= 500:
lastErr = fmt.Errorf("%s returned %d: %s", label, resp.StatusCode, Truncate(string(respBody), 300))
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
return waitErr
}
continue
case resp.StatusCode != http.StatusOK:
// 4xx other than 429: not retryable.
return fmt.Errorf("%s returned %d: %s", label, resp.StatusCode, Truncate(string(respBody), 300))
}
parseErr := parse(respBody)
if parseErr == nil {
return nil
}
var perm *permanentError
if errors.As(parseErr, &perm) {
return perm.err
}
lastErr = parseErr
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
return waitErr
}
}
if lastErr == nil {
lastErr = fmt.Errorf("%s: retries exhausted", label)
}
return lastErr
}
// endpointURL joins a configured base URL with an OpenAI API path,
// tolerating bases that already include the version segment (e.g.
// DeepInfra's https://api.deepinfra.com/v1/openai) alongside bare hosts
// (https://api.openai.com) and prefixed hosts (https://api.groq.com/openai).
func endpointURL(base, path string) string {
base = strings.TrimRight(base, "/")
if strings.Contains(base, "/v1") {
return base + "/" + path
}
return base + "/v1/" + path
}
// Truncate caps a string for inclusion in an error or log line.
func Truncate(s string, maxLen int) string {
if len(s) > maxLen {
return s[:maxLen] + "..."
}
return s
}
// sleepCtx waits for d or until ctx is cancelled.
func sleepCtx(ctx context.Context, d time.Duration) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(d):
return nil
}
}
// rateLimitBackoff returns how long to wait after a 429, honoring Retry-After
// and otherwise backing off exponentially (capped at 60s).
func rateLimitBackoff(resp *http.Response, attempt int) time.Duration {
if ra := resp.Header.Get("Retry-After"); ra != "" {
if secs, err := strconv.Atoi(ra); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
}
}
wait := 10 * time.Second * (1 << attempt)
if wait > 60*time.Second {
wait = 60 * time.Second
}
return wait
}
+350
View File
@@ -0,0 +1,350 @@
package llm
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
func chatConfig(baseURL string) Config {
return Config{BaseURL: baseURL, APIKey: "test-key", ChatModel: "test-model"}
}
const chatOK = `{"choices":[{"message":{"role":"assistant","content":"hello"}}]}`
func TestChatSuccessSendsAuthAndModel(t *testing.T) {
var gotAuth, gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
buf := make([]byte, 4096)
n, _ := r.Body.Read(buf)
gotBody = string(buf[:n])
w.Write([]byte(chatOK))
}))
defer srv.Close()
c := NewClient(chatConfig(srv.URL))
out, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, true)
if err != nil {
t.Fatalf("Chat: %v", err)
}
if out != "hello" {
t.Errorf("content = %q, want hello", out)
}
if gotAuth != "Bearer test-key" {
t.Errorf("auth = %q", gotAuth)
}
if !strings.Contains(gotBody, `"model":"test-model"`) || !strings.Contains(gotBody, `"json_object"`) {
t.Errorf("request body missing model/response_format: %s", gotBody)
}
}
func TestChatRetriesOn429HonoringRetryAfter(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if calls.Add(1) == 1 {
w.Header().Set("Retry-After", "1")
w.WriteHeader(http.StatusTooManyRequests)
return
}
w.Write([]byte(chatOK))
}))
defer srv.Close()
start := time.Now()
c := NewClient(chatConfig(srv.URL))
if _, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, false); err != nil {
t.Fatalf("Chat: %v", err)
}
if calls.Load() != 2 {
t.Errorf("calls = %d, want 2", calls.Load())
}
if elapsed := time.Since(start); elapsed < time.Second {
t.Errorf("did not honor Retry-After: elapsed %v", elapsed)
}
}
func TestChatRetriesOn5xxAndEmbeddedErrorAndEmptyChoices(t *testing.T) {
responses := []func(w http.ResponseWriter){
func(w http.ResponseWriter) { w.WriteHeader(http.StatusBadGateway) },
func(w http.ResponseWriter) { w.Write([]byte(`{"error":{"message":"upstream sad"}}`)) },
func(w http.ResponseWriter) { w.Write([]byte(`{"choices":[]}`)) },
func(w http.ResponseWriter) { w.Write([]byte(chatOK)) },
}
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
responses[calls.Add(1)-1](w)
}))
defer srv.Close()
c := NewClient(chatConfig(srv.URL))
out, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, false)
if err != nil {
t.Fatalf("Chat: %v", err)
}
if out != "hello" || calls.Load() != 4 {
t.Errorf("out=%q calls=%d, want hello/4", out, calls.Load())
}
}
func TestChatFailsFastOnNon429ClientError(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
c := NewClient(chatConfig(srv.URL))
if _, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, false); err == nil {
t.Fatal("expected error")
}
if calls.Load() != 1 {
t.Errorf("calls = %d, want 1 (no retry on 401)", calls.Load())
}
}
const verboseJSON = `{"language":"english","text":"hi there","segments":[{"start":0.0,"end":1.5,"text":" hi"},{"start":1.5,"end":3.0,"text":" there"}]}`
func TestTranscribeParsesSegmentsAndMultipart(t *testing.T) {
var gotModel, gotFormat, gotLang, gotAuth, gotVAD string
var gotGranularities []string
var gotFile []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
if err := r.ParseMultipartForm(32 << 20); err != nil {
t.Errorf("parse multipart: %v", err)
}
gotModel = r.FormValue("model")
gotFormat = r.FormValue("response_format")
gotLang = r.FormValue("language")
gotVAD = r.FormValue("vad_filter")
gotGranularities = r.MultipartForm.Value["timestamp_granularities[]"]
f, _, err := r.FormFile("file")
if err == nil {
buf := make([]byte, 64)
n, _ := f.Read(buf)
gotFile = buf[:n]
f.Close()
}
w.Write([]byte(verboseJSON))
}))
defer srv.Close()
cfg := chatConfig(srv.URL)
cfg.ASRModel = "whisper-test"
c := NewClient(cfg)
tr, err := c.Transcribe(context.Background(), TranscribeRequest{
Filename: "chunk.wav", Audio: []byte("RIFFfake"), Language: "ja",
})
if err != nil {
t.Fatalf("Transcribe: %v", err)
}
if gotModel != "whisper-test" || gotFormat != "verbose_json" || gotLang != "ja" {
t.Errorf("fields model=%q format=%q lang=%q", gotModel, gotFormat, gotLang)
}
// Local (non-hosted) endpoint: faster-whisper VAD must be requested or
// segment timestamps stretch wall-to-wall across silence.
if gotVAD != "true" {
t.Errorf("vad_filter = %q, want true for a self-hosted endpoint", gotVAD)
}
if fmt.Sprint(gotGranularities) != "[segment word]" {
t.Errorf("timestamp_granularities[] = %v, want [segment word]", gotGranularities)
}
if gotAuth != "Bearer test-key" {
t.Errorf("auth = %q (should fall back to chat key)", gotAuth)
}
if string(gotFile) != "RIFFfake" {
t.Errorf("file payload = %q", gotFile)
}
if tr.Language != "english" || len(tr.Segments) != 2 || tr.Segments[1].Text != " there" || tr.Segments[1].End != 3.0 {
t.Errorf("unexpected transcription: %+v", tr)
}
}
func TestTranscribeOmitsVADFilterForStrictHostedEndpoints(t *testing.T) {
for url, wantVAD := range map[string]bool{
"https://api.openai.com": false,
"https://myorg.openai.azure.com": false,
"https://api.groq.com/openai": false,
"http://192.168.1.10:8000": true,
"https://whisper.example.com": true,
"https://api.deepinfra.com/v1/openai": true,
} {
got := !hostMatchesAny(url, strictHostedASRHosts)
if got != wantVAD {
t.Errorf("vad_filter for %s = %v, want %v", url, got, wantVAD)
}
}
}
func TestTranscribeParsesPerSegmentWords(t *testing.T) {
// speaches/faster-whisper shape: words nested inside each segment.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"language":"en","text":"hi there","segments":[
{"start":0.0,"end":3.0,"text":" hi there","words":[
{"start":0.2,"end":0.5,"word":" hi"},{"start":0.6,"end":1.0,"word":" there"}]}]}`))
}))
defer srv.Close()
cfg := chatConfig(srv.URL)
cfg.ASRModel = "whisper-test"
tr, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
if err != nil {
t.Fatalf("Transcribe: %v", err)
}
words := tr.Segments[0].Words
if len(words) != 2 || words[1].Text != " there" || words[1].Start != 0.6 || words[1].End != 1.0 {
t.Errorf("segment words = %+v", words)
}
}
func TestTranscribeAttachesTopLevelWordsBySegmentTime(t *testing.T) {
// OpenAI shape: words in a top-level array, segments without words.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"language":"en","text":"hi there friend","segments":[
{"start":0.0,"end":1.5,"text":" hi there"},{"start":1.5,"end":3.0,"text":" friend"}],
"words":[{"start":0.2,"end":0.5,"word":"hi"},{"start":0.6,"end":1.0,"word":"there"},
{"start":1.8,"end":2.2,"word":"friend"}]}`))
}))
defer srv.Close()
cfg := chatConfig(srv.URL)
cfg.ASRModel = "whisper-test"
tr, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
if err != nil {
t.Fatalf("Transcribe: %v", err)
}
if len(tr.Segments[0].Words) != 2 || len(tr.Segments[1].Words) != 1 {
t.Errorf("word distribution = %d/%d, want 2/1", len(tr.Segments[0].Words), len(tr.Segments[1].Words))
}
if tr.Segments[1].Words[0].Text != "friend" {
t.Errorf("segment 1 word = %+v", tr.Segments[1].Words[0])
}
}
func TestTranscribeEmptySegmentsIsNotAnError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"language":"english","text":"","segments":[]}`))
}))
defer srv.Close()
cfg := chatConfig(srv.URL)
cfg.ASRModel = "whisper-test"
tr, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
if err != nil {
t.Fatalf("Transcribe: %v", err)
}
if len(tr.Segments) != 0 {
t.Errorf("segments = %v, want empty", tr.Segments)
}
}
func TestTranscribeMissingSegmentsFieldFailsFast(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.Write([]byte(`{"text":"plain response without segments"}`))
}))
defer srv.Close()
cfg := chatConfig(srv.URL)
cfg.ASRModel = "whisper-test"
_, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
if err == nil || !strings.Contains(err.Error(), "verbose_json") {
t.Fatalf("err = %v, want verbose_json complaint", err)
}
if calls.Load() != 1 {
t.Errorf("calls = %d, want 1 (permanent error must not retry)", calls.Load())
}
}
func TestTranscribeUsesASROverrides(t *testing.T) {
var gotAuth atomic.Value
asrSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth.Store(r.Header.Get("Authorization"))
w.Write([]byte(verboseJSON))
}))
defer asrSrv.Close()
cfg := Config{
BaseURL: "http://chat.invalid", APIKey: "chat-key", ChatModel: "m",
ASRBaseURL: asrSrv.URL, ASRAPIKey: "asr-key", ASRModel: "whisper-test",
}
if _, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")}); err != nil {
t.Fatalf("Transcribe: %v", err)
}
if gotAuth.Load() != "Bearer asr-key" {
t.Errorf("auth = %q, want asr-key", gotAuth.Load())
}
}
func TestTranscribeRequiresConfig(t *testing.T) {
c := NewClient(Config{BaseURL: "http://x", ChatModel: "m"}) // no ASR model
if _, err := c.Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")}); err == nil {
t.Fatal("expected not-configured error")
}
}
func TestTranscribeChatOnlyGatewayGetsConfigHint(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":{"message":"invalid content-type: multipart/form-data","code":400}}`))
}))
defer srv.Close()
cfg := chatConfig(srv.URL)
cfg.ASRModel = "whisper-test"
_, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
if err == nil || !strings.Contains(err.Error(), "Whisper-compatible Transcription base URL") {
t.Fatalf("err = %v, want configuration hint", err)
}
}
func TestIsChatOnlyGateway(t *testing.T) {
cases := map[string]bool{
"https://openrouter.ai/api": true,
"https://openrouter.ai": true,
"openrouter.ai/api/v1": true,
"https://gateway.openrouter.ai": true,
"https://api.groq.com/openai": false,
"https://api.openai.com": false,
"http://localhost:8000": false,
"https://my-openrouter.ai.example.io": false,
"": false,
}
for in, want := range cases {
if got := IsChatOnlyGateway(in); got != want {
t.Errorf("IsChatOnlyGateway(%q) = %v, want %v", in, got, want)
}
}
}
func TestTranscribeRefusesChatOnlyGateway(t *testing.T) {
cfg := Config{BaseURL: "https://openrouter.ai/api", ChatModel: "m", ASRModel: "whisper-1"}
_, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
if err == nil || !strings.Contains(err.Error(), "cannot produce timestamped transcriptions") {
t.Fatalf("err = %v, want chat-only refusal", err)
}
}
func TestEndpointURLToleratesVersionedBases(t *testing.T) {
cases := map[string]string{
"https://api.openai.com": "https://api.openai.com/v1/chat/completions",
"https://api.groq.com/openai": "https://api.groq.com/openai/v1/chat/completions",
"https://api.deepinfra.com/v1/openai": "https://api.deepinfra.com/v1/openai/chat/completions",
"http://localhost:8969": "http://localhost:8969/v1/chat/completions",
"http://localhost:8969/v1": "http://localhost:8969/v1/chat/completions",
"https://api.openai.com/": "https://api.openai.com/v1/chat/completions",
}
for base, want := range cases {
if got := endpointURL(base, "chat/completions"); got != want {
t.Errorf("endpointURL(%q) = %q, want %q", base, got, want)
}
}
}
+286
View File
@@ -0,0 +1,286 @@
package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/url"
"strings"
"time"
)
// defaultTranscribeTimeout bounds a single transcription request when the
// caller does not size one to its chunk duration. Generous on purpose: local
// Whisper servers on modest hardware can run well below realtime.
const defaultTranscribeTimeout = 20 * time.Minute
// TranscribeRequest is one audio-transcription call. Audio is held in memory
// so the request can be rebuilt across retries; callers chunk long files
// (a 10-minute 16 kHz mono WAV is ~19 MB).
type TranscribeRequest struct {
Filename string // e.g. "chunk00001.wav"; the extension hints the container
Audio []byte
Language string // optional ISO-639-1 hint; empty lets the model detect
Timeout time.Duration // per-request deadline; 0 uses defaultTranscribeTimeout
}
// TranscriptionSegment is one timed segment of recognized speech, in seconds
// relative to the start of the submitted audio.
type TranscriptionSegment struct {
Start float64
End float64
Text string
// Words carries per-word timings when the endpoint honors
// timestamp_granularities[]=word; empty otherwise. Word-level times let
// cue building split paragraph-length segments and end cues when speech
// actually stops instead of when the next segment begins.
Words []TranscriptionWord
}
// TranscriptionWord is one recognized word with timing, in seconds relative
// to the start of the submitted audio.
type TranscriptionWord struct {
Start float64
End float64
Text string
}
// Transcription is the parsed verbose_json transcription result.
type Transcription struct {
// Language is the detected (or hinted) language as reported by the
// endpoint. OpenAI returns an English language name ("english"); other
// servers return ISO codes. Callers must normalize.
Language string
// Segments may legitimately be empty for speech-free audio (silence,
// music-only chunks).
Segments []TranscriptionSegment
}
type transcriptionWordJSON struct {
Start float64 `json:"start"`
End float64 `json:"end"`
Word string `json:"word"`
}
type transcriptionResponse struct {
Language string `json:"language"`
Text string `json:"text"`
// Segments distinguishes "verbose_json honored, no speech" (empty array)
// from "endpoint ignored verbose_json" (field absent) — the latter cannot
// produce timed cues and must fail rather than silently emit nothing.
Segments *[]struct {
Start float64 `json:"start"`
End float64 `json:"end"`
Text string `json:"text"`
Words []transcriptionWordJSON `json:"words"`
} `json:"segments"`
// Words is where OpenAI puts word timings; faster-whisper servers
// (speaches) nest them inside each segment instead.
Words []transcriptionWordJSON `json:"words"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
// chatOnlyGatewayHosts lists OpenAI-compatible gateways that cannot serve
// Silo's transcription needs: they either lack /v1/audio/transcriptions
// entirely or (OpenRouter) return plain text with no segment timestamps,
// which subtitle cues require. Matched by host suffix.
var chatOnlyGatewayHosts = []string{
"openrouter.ai",
}
// strictHostedASRHosts lists hosted transcription providers that reject
// multipart fields outside the OpenAI spec, so the faster-whisper-only
// vad_filter field must be omitted for them. Nothing is lost: hosted Whisper
// runs voice-activity detection server-side. Self-hosted servers (speaches,
// faster-whisper) need the explicit field — without it segment timestamps
// stretch wall-to-wall across silence. Matched by host suffix.
var strictHostedASRHosts = []string{
"api.openai.com",
"openai.azure.com",
"api.groq.com",
"api.mistral.ai",
}
// IsChatOnlyGateway reports whether baseURL points at a known chat-only
// gateway that cannot produce timestamped transcriptions. Used to validate
// the transcription settings and to disable ASR rather than fail jobs at
// runtime.
func IsChatOnlyGateway(baseURL string) bool {
return hostMatchesAny(baseURL, chatOnlyGatewayHosts)
}
// hostMatchesAny reports whether baseURL's hostname equals or is a subdomain
// of any of the given host suffixes.
func hostMatchesAny(baseURL string, hosts []string) bool {
baseURL = strings.TrimSpace(baseURL)
if baseURL == "" {
return false
}
if !strings.Contains(baseURL, "://") {
baseURL = "https://" + baseURL
}
u, err := url.Parse(baseURL)
if err != nil || u.Host == "" {
return false
}
host := strings.ToLower(u.Hostname())
for _, h := range hosts {
if host == h || strings.HasSuffix(host, "."+h) {
return true
}
}
return false
}
// Transcribe performs one audio transcription against the ASR endpoint
// (falling back to the chat endpoint's base URL/key when no ASR override is
// configured), using the OpenAI-compatible /v1/audio/transcriptions API with
// response_format=verbose_json for segment timestamps.
func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transcription, error) {
if !c.cfg.ASRConfigured() {
return nil, fmt.Errorf("transcription endpoint is not configured")
}
if IsChatOnlyGateway(c.cfg.asrBaseURL()) {
return nil, fmt.Errorf("the configured transcription endpoint (%s) cannot produce timestamped transcriptions; "+
"set a Whisper-compatible Transcription base URL under Admin Settings → AI Services", c.cfg.asrBaseURL())
}
if len(req.Audio) == 0 {
return nil, fmt.Errorf("no audio data to transcribe")
}
timeout := req.Timeout
if timeout <= 0 {
timeout = defaultTranscribeTimeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
url := endpointURL(c.cfg.asrBaseURL(), "audio/transcriptions")
var result *Transcription
doErr := c.doWithRetry(ctx, c.asrHTTP, "transcription API",
func() (*http.Request, error) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
fw, err := w.CreateFormFile("file", req.Filename)
if err != nil {
return nil, fmt.Errorf("create multipart file: %w", err)
}
if _, err := fw.Write(req.Audio); err != nil {
return nil, fmt.Errorf("write multipart audio: %w", err)
}
fields := [][2]string{
{"model", c.cfg.ASRModel},
{"response_format", "verbose_json"},
{"temperature", "0"},
// Word timings let cue building split paragraph-length
// segments and end cues when speech actually stops; segment
// granularity must be requested alongside or OpenAI omits it.
{"timestamp_granularities[]", "segment"},
{"timestamp_granularities[]", "word"},
}
if req.Language != "" {
fields = append(fields, [2]string{"language", req.Language})
}
if !hostMatchesAny(c.cfg.asrBaseURL(), strictHostedASRHosts) {
fields = append(fields, [2]string{"vad_filter", "true"})
}
for _, f := range fields {
if err := w.WriteField(f[0], f[1]); err != nil {
return nil, fmt.Errorf("write multipart field %s: %w", f[0], err)
}
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("finalize multipart body: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf.Bytes()))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
httpReq.Header.Set("Content-Type", w.FormDataContentType())
if key := c.cfg.asrAPIKey(); key != "" {
httpReq.Header.Set("Authorization", "Bearer "+key)
}
return httpReq, nil
},
func(respBody []byte) error {
var parsed transcriptionResponse
if err := json.Unmarshal(respBody, &parsed); err != nil {
return fmt.Errorf("decode transcription response: %w", err)
}
if parsed.Error != nil && parsed.Error.Message != "" {
return fmt.Errorf("transcription API error: %s", parsed.Error.Message)
}
if parsed.Segments == nil {
return &permanentError{err: fmt.Errorf(
"transcription endpoint did not return verbose_json segments (model %q); a verbose_json-capable Whisper endpoint is required", c.cfg.ASRModel)}
}
out := &Transcription{Language: parsed.Language}
for _, s := range *parsed.Segments {
out.Segments = append(out.Segments, TranscriptionSegment{
Start: s.Start, End: s.End, Text: s.Text, Words: wordsFromJSON(s.Words),
})
}
attachTopLevelWords(out.Segments, wordsFromJSON(parsed.Words))
result = out
return nil
})
if doErr != nil {
return nil, decorateTranscribeError(doErr)
}
return result, nil
}
func wordsFromJSON(words []transcriptionWordJSON) []TranscriptionWord {
out := make([]TranscriptionWord, 0, len(words))
for _, w := range words {
out = append(out, TranscriptionWord{Start: w.Start, End: w.End, Text: w.Word})
}
return out
}
// attachTopLevelWords distributes an OpenAI-style top-level word list onto
// segments by word midpoint, for endpoints that report words separately from
// segments. Segments that already carry their own words are left untouched.
func attachTopLevelWords(segments []TranscriptionSegment, words []TranscriptionWord) {
if len(segments) == 0 || len(words) == 0 {
return
}
for _, s := range segments {
if len(s.Words) > 0 {
return
}
}
si := 0
for _, w := range words {
mid := (w.Start + w.End) / 2
for si < len(segments)-1 && mid >= segments[si].End {
si++
}
segments[si].Words = append(segments[si].Words, w)
}
}
// decorateTranscribeError appends a configuration hint to the errors a
// chat-only gateway produces when it receives a transcription upload (no
// /v1/audio/transcriptions route, or multipart rejected). Operators routinely
// point the shared base URL at a chat-only provider; without the hint the raw
// 400/404 reads like a pipeline bug instead of "set a Whisper endpoint".
func decorateTranscribeError(err error) error {
msg := err.Error()
likelyUnsupported := strings.Contains(msg, "returned 400") ||
strings.Contains(msg, "returned 404") ||
strings.Contains(msg, "returned 405")
if !likelyUnsupported {
return err
}
return fmt.Errorf("%w — the configured endpoint likely does not support audio transcription; "+
"set a Whisper-compatible Transcription base URL (and model) under Admin Settings → AI Services "+
"(e.g. a self-hosted faster-whisper/speaches server, api.groq.com/openai with whisper-large-v3-turbo, or api.openai.com with whisper-1)", err)
}
@@ -1,4 +1,4 @@
package ai
package aitranslate
import "strings"
@@ -18,9 +18,25 @@ var languageNames = map[string]string{
"tr": "Turkish", "uk": "Ukrainian", "vi": "Vietnamese", "zh": "Chinese",
}
// languageDisplayName returns a human-readable language name for a code, or the
// trimmed code itself when unknown. An empty code yields an empty string.
func languageDisplayName(code string) string {
// LanguageCodeFromName maps an English language name back to its ISO 639-1
// code — Whisper endpoints report detected languages as names ("english")
// while local servers report codes. Returns "" when unknown.
func LanguageCodeFromName(name string) string {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
return ""
}
for code, display := range languageNames {
if strings.ToLower(display) == name {
return code
}
}
return ""
}
// LanguageDisplayName returns a human-readable language name for a code, or
// the trimmed code itself when unknown. An empty code yields an empty string.
func LanguageDisplayName(code string) string {
code = strings.TrimSpace(code)
if code == "" {
return ""
+212
View File
@@ -0,0 +1,212 @@
// 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
}
+153
View File
@@ -0,0 +1,153 @@
package aitranslate
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"testing"
)
// echoChat translates by upper-casing every value of the indexed JSON payload
// in the user message, mimicking a well-behaved model.
func echoChat(t *testing.T) ChatFn {
t.Helper()
return func(_ context.Context, _ string, user string) (string, error) {
obj, err := extractJSONObject(user)
if err != nil {
return "", fmt.Errorf("test chat: %w", err)
}
var m map[string]string
if err := json.Unmarshal([]byte(obj), &m); err != nil {
return "", fmt.Errorf("test chat decode: %w", 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 segments(n int) []Segment {
out := make([]Segment, n)
for i := range out {
out[i] = Segment{ID: strconv.Itoa(i + 1), Text: fmt.Sprintf("line %d", i+1)}
}
return out
}
func TestTranslateBatchesAndPreservesIDs(t *testing.T) {
var batches []int
out, err := Translate(context.Background(), echoChat(t), Request{
Segments: segments(7),
BatchSize: 3,
}, func(batch []Segment, done, total int) {
batches = append(batches, len(batch))
if total != 7 {
t.Errorf("total = %d, want 7", total)
}
})
if err != nil {
t.Fatalf("Translate: %v", err)
}
if len(out) != 7 {
t.Fatalf("len(out) = %d", len(out))
}
for i, s := range out {
if s.ID != strconv.Itoa(i+1) {
t.Errorf("ID[%d] = %q, want %d", i, s.ID, i+1)
}
if want := strings.ToUpper(fmt.Sprintf("line %d", i+1)); s.Text != want {
t.Errorf("Text[%d] = %q, want %q", i, s.Text, want)
}
}
if fmt.Sprint(batches) != "[3 3 1]" {
t.Errorf("batch sizes = %v, want [3 3 1]", batches)
}
}
func TestTranslateSendsContextNeighborsUntranslated(t *testing.T) {
var secondBatchMsg string
call := 0
chat := func(ctx context.Context, system, user string) (string, error) {
call++
if call == 2 {
secondBatchMsg = user
}
return echoChat(t)(ctx, system, user)
}
_, err := Translate(context.Background(), chat, Request{
Segments: segments(4),
BatchSize: 2,
ContextNeighbors: 2,
TargetName: "French",
EntryNoun: "cues",
}, nil)
if err != nil {
t.Fatalf("Translate: %v", err)
}
if !strings.Contains(secondBatchMsg, "context only") ||
!strings.Contains(secondBatchMsg, "line 1\nline 2") {
t.Errorf("second batch missing context block:\n%s", secondBatchMsg)
}
if !strings.Contains(secondBatchMsg, "Translate these 2 cues into French.") {
t.Errorf("second batch missing instruction:\n%s", secondBatchMsg)
}
}
func TestTranslateToleratesCodeFences(t *testing.T) {
chat := func(_ context.Context, _, _ string) (string, error) {
return "Sure! Here you go:\n```json\n{\"1\":\"ok\"}\n```", nil
}
out, err := Translate(context.Background(), chat, Request{
Segments: segments(1),
BatchSize: 5,
}, nil)
if err != nil {
t.Fatalf("Translate: %v", err)
}
if out[0].Text != "ok" {
t.Errorf("Text = %q", out[0].Text)
}
}
func TestTranslateRetriesOmittedKeysThenFails(t *testing.T) {
calls := 0
chat := func(_ context.Context, _, _ string) (string, error) {
calls++
return `{"1":"only one"}`, nil // always omits key "2"
}
_, err := Translate(context.Background(), chat, Request{
Segments: segments(2),
BatchSize: 5,
}, nil)
if err == nil || !strings.Contains(err.Error(), "omitted") {
t.Fatalf("err = %v, want omission failure", err)
}
if calls != maxFormatRetries+1 {
t.Errorf("calls = %d, want %d", calls, maxFormatRetries+1)
}
}
func TestTranslateStopsOnCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
calls := 0
chat := func(_ context.Context, _, _ string) (string, error) {
calls++
cancel() // cancel after the first batch completes
return `{"1":"a"}`, nil
}
_, err := Translate(ctx, chat, Request{
Segments: segments(3),
BatchSize: 1,
}, nil)
if err == nil || !strings.Contains(err.Error(), "context canceled") {
t.Fatalf("err = %v, want context cancellation", err)
}
if calls != 1 {
t.Errorf("calls = %d, want 1", calls)
}
}
+16
View File
@@ -20,6 +20,7 @@ import (
"golang.org/x/sync/errgroup"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/ai/llm"
"github.com/Silo-Server/silo-server/internal/adminjob"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
@@ -2101,6 +2102,21 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
} else {
req.Value = normalized
}
case "ai.asr_base_url":
if llm.IsChatOnlyGateway(req.Value) {
writeError(w, http.StatusBadRequest, "bad_request",
"This endpoint cannot produce timestamped transcriptions (chat-only gateway). "+
"Use a self-hosted Whisper server (faster-whisper/speaches), api.groq.com/openai, or api.openai.com.")
return
}
case "metadata_ai.on_view":
switch req.Value {
case "off", "button", "auto":
default:
writeError(w, http.StatusBadRequest, "bad_request",
"metadata_ai.on_view must be off, button, or auto")
return
}
}
if err := h.SettingsRepo.Set(r.Context(), key, req.Value); err != nil {
+9 -8
View File
@@ -1497,14 +1497,15 @@ func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter {
if scope, ok := access.GetScope(r.Context()); ok {
return catalog.AccessFilter{
AllowedLibraryIDs: scope.AllowedLibraryIDs,
DisabledLibraryIDs: scope.DisabledLibraryIDs,
MaxContentRating: scope.MaxContentRating,
MaxPlaybackQuality: scope.MaxPlaybackQuality,
PresentationLibraryID: presentationLibraryID,
SelectedFileID: selectedFileID,
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
AllowedLibraryIDs: scope.AllowedLibraryIDs,
DisabledLibraryIDs: scope.DisabledLibraryIDs,
MaxContentRating: scope.MaxContentRating,
MaxPlaybackQuality: scope.MaxPlaybackQuality,
PresentationLibraryID: presentationLibraryID,
ProfilePreferredLanguage: scope.PreferredMetadataLanguage,
SelectedFileID: selectedFileID,
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
}
}
+4
View File
@@ -192,6 +192,7 @@ type updateLibraryRequest struct {
Name *string `json:"name,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
MetadataLanguage *string `json:"metadata_language,omitempty"`
AutoTranslateMetadata *bool `json:"auto_translate_metadata,omitempty"`
ChapterThumbnailsEnabled *bool `json:"chapter_thumbnails_enabled,omitempty"`
IntroDetectionEnabled *bool `json:"intro_detection_enabled,omitempty"`
}
@@ -226,6 +227,7 @@ type libraryResponse struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
MetadataLanguage string `json:"metadata_language"`
AutoTranslateMetadata bool `json:"auto_translate_metadata"`
ChapterThumbnailsEnabled bool `json:"chapter_thumbnails_enabled"`
ChapterThumbnailsSupported bool `json:"chapter_thumbnails_supported"`
IntroDetectionEnabled bool `json:"intro_detection_enabled"`
@@ -348,6 +350,7 @@ func toLibraryResponse(f *models.MediaFolder) libraryResponse {
Name: f.Name,
Enabled: f.Enabled,
MetadataLanguage: f.MetadataLanguage,
AutoTranslateMetadata: f.AutoTranslateMetadata,
ChapterThumbnailsEnabled: f.ChapterThumbnailsEnabled,
ChapterThumbnailsSupported: false,
IntroDetectionEnabled: f.IntroDetectionEnabled,
@@ -652,6 +655,7 @@ func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Requ
Name: req.Name,
Enabled: req.Enabled,
MetadataLanguage: req.MetadataLanguage,
AutoTranslateMetadata: req.AutoTranslateMetadata,
ChapterThumbnailsEnabled: req.ChapterThumbnailsEnabled,
IntroDetectionEnabled: req.IntroDetectionEnabled,
})
+212
View File
@@ -0,0 +1,212 @@
package handlers
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/access"
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/metadata/translation"
)
// MetadataAIHandler exposes AI translation of catalog descriptions into the
// localization tables. The admin routes are mounted under the per-item
// metadata curation guard; the on-view route is viewer-facing and enforces
// item access itself.
type MetadataAIHandler struct {
service *translation.Service
// ItemAccess authorizes the viewer-facing on-view route; nil disables it.
ItemAccess *catalog.ItemRepository
}
// NewMetadataAIHandler creates a handler backed by the given service.
func NewMetadataAIHandler(service *translation.Service) *MetadataAIHandler {
return &MetadataAIHandler{service: service}
}
// HandleStatus reports whether metadata AI translation is available and the
// viewer-facing on-view mode, so the metadata editor and detail pages can
// show or hide their entry points.
// GET /api/v1/metadata/ai/status
func (h *MetadataAIHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"enabled": h.service.Enabled(),
"on_view": h.service.OnViewMode(),
})
}
// WriteMetadataAIDisabledStatus answers the status probe with a clean negative
// when no metadata AI handler is wired.
func WriteMetadataAIDisabledStatus(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"enabled": false, "on_view": "off"})
}
type translateDescriptionRequest struct {
// TargetLanguage echoes the detail response's pending_translation_language.
TargetLanguage string `json:"target_language"`
}
// HandleTranslateOnView is the viewer-facing on-demand description
// translation: any profile that can access the item may request its
// descriptions in the language the detail response reported missing. Gated by
// metadata_ai.on_view; duplicate viewers collapse onto one job and recently
// failed targets are not retried (cooldown in the service).
// POST /api/v1/items/{id}/translate-description
func (h *MetadataAIHandler) HandleTranslateOnView(w http.ResponseWriter, r *http.Request) {
contentID := chi.URLParam(r, "id")
var req translateDescriptionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body")
return
}
if req.TargetLanguage == "" {
writeError(w, http.StatusBadRequest, "bad_request", "target_language is required")
return
}
scope, ok := access.GetScope(r.Context())
if !ok || h.ItemAccess == nil {
writeError(w, http.StatusForbidden, "forbidden", "Viewer access is required")
return
}
filter := catalog.AccessFilter{
AllowedLibraryIDs: scope.AllowedLibraryIDs,
DisabledLibraryIDs: scope.DisabledLibraryIDs,
MaxContentRating: scope.MaxContentRating,
UserID: scope.UserID,
ProfileID: scope.ProfileID,
}
if err := h.ItemAccess.EnsureAccessible(r.Context(), contentID, filter); err != nil {
if errors.Is(err, catalog.ErrItemNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize item")
return
}
var requestedBy *int
if userID := apimw.GetUserID(r.Context()); userID != 0 {
requestedBy = &userID
}
job, err := h.service.RequestOnView(r.Context(), contentID, req.TargetLanguage, requestedBy)
if err != nil {
switch {
case errors.Is(err, translation.ErrNotConfigured):
writeError(w, http.StatusServiceUnavailable, "not_configured",
"On-view translation is not enabled on this server")
case errors.Is(err, translation.ErrInvalidRequest):
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
default:
slog.Error("failed to request on-view translation",
"content_id", contentID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start translation")
}
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"job": job})
}
type translateMetadataRequest struct {
TargetLanguage string `json:"target_language"`
IncludeChildren *bool `json:"include_children"` // default true
Force bool `json:"force"`
}
// HandleTranslate enqueues a translation job for an item.
// POST /api/v1/admin/items/{id}/metadata-translation
func (h *MetadataAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Request) {
contentID := chi.URLParam(r, "id")
var req translateMetadataRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body")
return
}
if req.TargetLanguage == "" {
writeError(w, http.StatusBadRequest, "bad_request", "target_language is required")
return
}
includeChildren := true
if req.IncludeChildren != nil {
includeChildren = *req.IncludeChildren
}
var requestedBy *int
if userID := apimw.GetUserID(r.Context()); userID != 0 {
requestedBy = &userID
}
job, err := h.service.Enqueue(r.Context(), translation.JobRequest{
TargetKind: translation.TargetItem,
ContentID: contentID,
TargetLanguage: req.TargetLanguage,
IncludeChildren: includeChildren,
Force: req.Force,
RequestedBy: requestedBy,
})
if err != nil {
switch {
case errors.Is(err, translation.ErrNotConfigured):
writeError(w, http.StatusServiceUnavailable, "not_configured",
"Metadata AI translation is not configured on this server")
case errors.Is(err, translation.ErrInvalidRequest):
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
default:
slog.Error("failed to enqueue metadata translation",
"content_id", contentID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start translation")
}
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"job": job})
}
// HandleListJobs lists recent translation jobs for an item; the metadata
// editor polls this for progress.
// GET /api/v1/admin/items/{id}/metadata-translation/jobs
func (h *MetadataAIHandler) HandleListJobs(w http.ResponseWriter, r *http.Request) {
jobs, err := h.service.ListJobs(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusInternalServerError, "list_error", "Failed to list jobs")
return
}
writeJSON(w, http.StatusOK, map[string]any{"jobs": jobs})
}
// HandleCancelJob cancels a job belonging to the item in the URL.
// POST /api/v1/admin/items/{id}/metadata-translation/jobs/{job_id}/cancel
func (h *MetadataAIHandler) HandleCancelJob(w http.ResponseWriter, r *http.Request) {
jobID, err := strconv.ParseInt(chi.URLParam(r, "job_id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id", "Invalid job ID")
return
}
job, err := h.service.GetJob(r.Context(), jobID)
if err != nil {
if errors.Is(err, translation.ErrJobNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Job not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load job")
return
}
// The curation guard authorized {id}; the job must belong to it.
if job.ContentID != chi.URLParam(r, "id") {
writeError(w, http.StatusNotFound, "not_found", "Job not found")
return
}
if err := h.service.Cancel(r.Context(), jobID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel job")
return
}
w.WriteHeader(http.StatusNoContent)
}
+6
View File
@@ -48,6 +48,7 @@ type createProfileRequest struct {
MaxContentRating string `json:"max_content_rating,omitempty"`
QualityPreference string `json:"quality_preference,omitempty"`
Language string `json:"language,omitempty"`
PreferredMetadataLanguage string `json:"preferred_metadata_language,omitempty"`
SubtitleLanguage string `json:"subtitle_language,omitempty"`
SubtitleMode string `json:"subtitle_mode,omitempty"`
AutoSkipIntro bool `json:"auto_skip_intro"`
@@ -68,6 +69,7 @@ type updateProfileRequest struct {
MaxContentRating *string `json:"max_content_rating,omitempty"`
QualityPreference *string `json:"quality_preference,omitempty"`
Language *string `json:"language,omitempty"`
PreferredMetadataLanguage *string `json:"preferred_metadata_language,omitempty"`
SubtitleLanguage *string `json:"subtitle_language,omitempty"`
SubtitleMode *string `json:"subtitle_mode,omitempty"`
AutoSkipIntro *bool `json:"auto_skip_intro,omitempty"`
@@ -96,6 +98,7 @@ type profileResponse struct {
MaxContentRating string `json:"max_content_rating,omitempty"`
QualityPreference string `json:"quality_preference,omitempty"`
Language string `json:"language,omitempty"`
PreferredMetadataLanguage string `json:"preferred_metadata_language,omitempty"`
SubtitleLanguage string `json:"subtitle_language,omitempty"`
SubtitleMode string `json:"subtitle_mode,omitempty"`
AutoSkipIntro bool `json:"auto_skip_intro"`
@@ -356,6 +359,7 @@ func (h *ProfileHandler) HandleCreateProfile(w http.ResponseWriter, r *http.Requ
MaxContentRating: req.MaxContentRating,
QualityPreference: req.QualityPreference,
Language: req.Language,
PreferredMetadataLanguage: req.PreferredMetadataLanguage,
SubtitleLanguage: req.SubtitleLanguage,
SubtitleMode: req.SubtitleMode,
AutoSkipIntro: req.AutoSkipIntro,
@@ -505,6 +509,7 @@ func (h *ProfileHandler) HandleUpdateProfile(w http.ResponseWriter, r *http.Requ
MaxContentRating: req.MaxContentRating,
QualityPreference: req.QualityPreference,
Language: req.Language,
PreferredMetadataLanguage: req.PreferredMetadataLanguage,
SubtitleLanguage: req.SubtitleLanguage,
SubtitleMode: req.SubtitleMode,
AutoSkipIntro: req.AutoSkipIntro,
@@ -684,6 +689,7 @@ func (h *ProfileHandler) toProfileResponse(ctx context.Context, p userstore.Prof
MaxContentRating: p.MaxContentRating,
QualityPreference: p.QualityPreference,
Language: p.Language,
PreferredMetadataLanguage: p.PreferredMetadataLanguage,
SubtitleLanguage: p.SubtitleLanguage,
SubtitleMode: p.SubtitleMode,
AutoSkipIntro: p.AutoSkipIntro,
+22 -7
View File
@@ -48,7 +48,12 @@ func authorizeMediaFileAccess(w http.ResponseWriter, r *http.Request, authorizer
}
type translateSubtitleRequest struct {
MediaFileID int `json:"media_file_id"`
MediaFileID int `json:"media_file_id"`
// Kind selects the job: "translate" (default), "transcribe", or
// "transcribe_translate". For ASR kinds, source_index is the audio track
// index (-1 = default track) and target_language is optional for plain
// transcribe (acts as a language hint).
Kind string `json:"kind"`
SourceIndex int `json:"source_index"`
SourceLanguage string `json:"source_language"`
TargetLanguage string `json:"target_language"`
@@ -56,10 +61,14 @@ type translateSubtitleRequest struct {
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
// HandleStatus reports whether AI subtitle translation / ASR generation are
// available, so the player can show or hide the entry points.
// 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()})
writeJSON(w, http.StatusOK, map[string]any{
"enabled": h.service.Enabled(),
"transcribe_enabled": h.service.TranscribeEnabled(),
})
}
// WriteSubtitleAIDisabledStatus answers the AI status capability probe with a
@@ -67,7 +76,7 @@ func (h *SubtitleAIHandler) HandleStatus(w http.ResponseWriter, r *http.Request)
// 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})
writeJSON(w, http.StatusOK, map[string]any{"enabled": false, "transcribe_enabled": false})
}
// HandleTranslate enqueues a translation job. POST /api/v1/subtitles/ai/translate
@@ -81,7 +90,13 @@ func (h *SubtitleAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque
writeError(w, http.StatusBadRequest, "bad_request", "media_file_id is required")
return
}
if req.TargetLanguage == "" {
kind := ai.JobKind(req.Kind)
if kind == "" {
kind = ai.JobKindTranslate
}
// A plain transcribe produces a track in the spoken language, so the
// target is optional; every other kind needs one.
if req.TargetLanguage == "" && kind != ai.JobKindTranscribe {
writeError(w, http.StatusBadRequest, "bad_request", "target_language is required")
return
}
@@ -97,7 +112,7 @@ func (h *SubtitleAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque
job, err := h.service.Enqueue(r.Context(), ai.JobRequest{
MediaFileID: req.MediaFileID,
Kind: ai.JobKindTranslate,
Kind: kind,
SourceIndex: req.SourceIndex,
SourceLanguage: req.SourceLanguage,
TargetLanguage: req.TargetLanguage,
+94 -6
View File
@@ -20,6 +20,8 @@ import (
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/activitylog"
"github.com/Silo-Server/silo-server/internal/adminjob"
"github.com/Silo-Server/silo-server/internal/ai/jobrunner"
"github.com/Silo-Server/silo-server/internal/ai/llm"
"github.com/Silo-Server/silo-server/internal/api/handlers"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
@@ -40,6 +42,7 @@ import (
"github.com/Silo-Server/silo-server/internal/metadata"
"github.com/Silo-Server/silo-server/internal/metadata/tmdb"
metatrakt "github.com/Silo-Server/silo-server/internal/metadata/trakt"
metadatatranslation "github.com/Silo-Server/silo-server/internal/metadata/translation"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/Silo-Server/silo-server/internal/opslog"
@@ -907,14 +910,45 @@ func NewRouter(deps Dependencies) chi.Router {
// 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.
// Shared AI endpoint client + dispatch semaphore: subtitle translation/ASR
// and metadata translation draw from one client and one concurrency bound.
var aiClient *llm.Client
var aiSem chan struct{}
if deps.Config != nil {
aiClient = llm.NewClient(llm.Config{
BaseURL: deps.Config.AI.BaseURL,
APIKey: deps.Config.AI.APIKey,
ChatModel: deps.Config.AI.ChatModel,
ASRBaseURL: deps.Config.AI.ASRBaseURL,
ASRAPIKey: deps.Config.AI.ASRAPIKey,
ASRModel: deps.Config.AI.ASRModel,
})
aiSem = jobrunner.NewSemaphore(deps.Config.AI.MaxConcurrentJobs)
}
var subtitleAIHandler *handlers.SubtitleAIHandler
if subtitleManager != nil && subtitleRepo != nil && deps.FileRepo != nil && deps.DB != nil && deps.Config != nil {
// A chat-only gateway (e.g. OpenRouter) cannot produce timestamped
// transcriptions; disable ASR rather than let every job fail. The
// settings API rejects such values for the ASR URL, but the chat base
// URL legitimately may be one — this catches the blank-ASR-URL
// fallback case.
transcribeEnabled := deps.Config.SubtitleAI.TranscribeEnabled
effectiveASRBase := deps.Config.AI.ASRBaseURL
if effectiveASRBase == "" {
effectiveASRBase = deps.Config.AI.BaseURL
}
if transcribeEnabled && llm.IsChatOnlyGateway(effectiveASRBase) {
slog.Warn("subtitle transcription disabled: the effective transcription endpoint is a chat-only gateway; "+
"set a Whisper-compatible Transcription base URL in AI Services", "endpoint", effectiveASRBase)
transcribeEnabled = false
}
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,
Configured: deps.Config.AI.BaseURL != "",
TranslateEnabled: deps.Config.SubtitleAI.Enabled,
TranscribeEnabled: transcribeEnabled,
ChatModel: deps.Config.AI.ChatModel,
ASRModel: deps.Config.AI.ASRModel,
BatchSize: deps.Config.SubtitleAI.BatchSize,
ContextNeighbors: deps.Config.SubtitleAI.ContextNeighbors,
}
@@ -926,18 +960,54 @@ func NewRouter(deps Dependencies) chi.Router {
deps.AppContext,
aiCfg,
subtitleai.NewPgJobRepository(deps.DB),
subtitleai.NewLLMTranslator(subtitleai.NewClient(aiCfg), aiCfg.BatchSize, aiCfg.ContextNeighbors),
subtitleai.NewLLMTranslator(aiClient, aiCfg.BatchSize, aiCfg.ContextNeighbors),
subtitleai.NewWhisperTranscriber(aiClient, deps.Config.Playback.FFmpegPath, deps.Config.SubtitleAI.ASRChunkSeconds),
subtitleManager,
subtitleRepo,
deps.FileRepo,
aiNotifier,
deps.Config.Playback.FFmpegPath,
slog.Default(),
aiSem,
)
aiService.Recover()
subtitleAIHandler = handlers.NewSubtitleAIHandler(aiService)
}
// Metadata AI translation (descriptions into the localization tables).
var metadataAIHandler *handlers.MetadataAIHandler
if deps.DB != nil && deps.Config != nil && aiClient != nil {
mtRepo := metadatatranslation.NewPgRepository(deps.DB)
mtService := metadatatranslation.NewService(
deps.AppContext,
metadatatranslation.Config{
Enabled: deps.Config.MetadataAI.Enabled,
Configured: deps.Config.AI.BaseURL != "",
ChatModel: deps.Config.AI.ChatModel,
OnView: deps.Config.MetadataAI.OnView,
},
mtRepo,
mtRepo,
&metadatatranslation.CatalogLocalizationStore{
Items: catalog.NewMediaItemLocalizationRepository(deps.DB),
Seasons: catalog.NewSeasonLocalizationRepository(deps.DB),
Episodes: catalog.NewEpisodeLocalizationRepository(deps.DB),
},
aiClient.SystemUserChat,
aiSem,
slog.Default(),
)
mtService.Recover()
metadataAIHandler = handlers.NewMetadataAIHandler(mtService)
// Wire the refresh fallback: libraries with auto_translate_metadata get
// missing localizations filled after each metadata refresh.
if mt, ok := deps.MetadataService.(interface {
SetAutoTranslator(metadata.AutoTranslator)
}); ok {
mt.SetAutoTranslator(mtService)
}
}
// Build section handler if DB is available.
var sectionHandler *handlers.SectionHandler
var sectionSettingsHandler *handlers.SectionSettingsHandler
@@ -1759,6 +1829,19 @@ func NewRouter(deps Dependencies) chi.Router {
})
}
// Metadata AI translation availability probe (the metadata editor
// and detail pages show or hide their translate actions based on
// this) plus the viewer-facing on-view translation trigger.
if metadataAIHandler != nil {
r.Get("/metadata/ai/status", metadataAIHandler.HandleStatus)
if itemRepo != nil {
metadataAIHandler.ItemAccess = itemRepo
r.Post("/items/{id}/translate-description", metadataAIHandler.HandleTranslateOnView)
}
} else {
r.Get("/metadata/ai/status", handlers.WriteMetadataAIDisabledStatus)
}
// Subtitle search + AI translation routes.
if subtitleSearchHandler != nil {
if deps.FileRepo != nil && itemRepo != nil {
@@ -1929,6 +2012,11 @@ func NewRouter(deps Dependencies) chi.Router {
r.Post("/items/{id}/match/search", adminMatchHandler.HandleSearchItemMatchCandidates)
r.Post("/items/{id}/match/apply", adminMatchHandler.HandleApplyItemMatch)
}
if metadataAIHandler != nil {
r.Post("/items/{id}/metadata-translation", metadataAIHandler.HandleTranslate)
r.Get("/items/{id}/metadata-translation/jobs", metadataAIHandler.HandleListJobs)
r.Post("/items/{id}/metadata-translation/jobs/{job_id}/cancel", metadataAIHandler.HandleCancelJob)
}
})
if adminJobsHandler != nil {
+5 -1
View File
@@ -15,7 +15,11 @@ type AccessFilter struct {
DisabledLibraryIDs []int // user-disabled libraries (only set when AllowedLibraryIDs is nil)
PresentationLibraryID *int
PresentationLanguage string
MaxContentRating string
// ProfilePreferredLanguage is the viewer profile's preferred metadata
// language. Presentation language resolves: explicit PresentationLanguage
// → ProfilePreferredLanguage → the library's metadata_language.
ProfilePreferredLanguage string
MaxContentRating string
MaxPlaybackQuality string
SelectedFileID int
UserID int
+36 -21
View File
@@ -74,6 +74,10 @@ type ItemDetail struct {
Year int `json:"year,omitempty"`
Overview string `json:"overview,omitempty"`
Tagline string `json:"tagline,omitempty"`
// PendingTranslationLanguage, when set, is the viewer's presentation
// language that the description is missing — the on-view AI translation
// affordance keys off it.
PendingTranslationLanguage string `json:"pending_translation_language,omitempty"`
Runtime int `json:"runtime,omitempty"`
ContentRating string `json:"content_rating,omitempty"`
Genres []string `json:"genres"`
@@ -576,10 +580,16 @@ func cloneEpisode(ep *models.Episode) *models.Episode {
return &cp
}
// resolvePresentationLanguage picks the display language for a request:
// explicit request language → viewer profile preference → the presentation
// library's metadata language.
func (s *DetailService) resolvePresentationLanguage(ctx context.Context, filter AccessFilter) (string, error) {
if strings.TrimSpace(filter.PresentationLanguage) != "" {
return strings.TrimSpace(filter.PresentationLanguage), nil
}
if strings.TrimSpace(filter.ProfilePreferredLanguage) != "" {
return strings.TrimSpace(filter.ProfilePreferredLanguage), nil
}
if filter.PresentationLibraryID == nil || s.folderRepo == nil {
return "", nil
}
@@ -593,6 +603,27 @@ func (s *DetailService) resolvePresentationLanguage(ctx context.Context, filter
return strings.TrimSpace(folder.MetadataLanguage), nil
}
// PendingTranslationLanguage reports the presentation language the item's
// description is missing: non-empty when the resolved language differs from
// the item's base metadata language, the base overview has text, and no
// localized overview exists yet. Clients use it to offer (or auto-run)
// on-view AI translation; it is pure data, independent of whether the AI
// feature is enabled.
func (s *DetailService) PendingTranslationLanguage(ctx context.Context, item *models.MediaItem, filter AccessFilter) string {
if item == nil || strings.TrimSpace(item.Overview) == "" || s.itemLocRepo == nil {
return ""
}
language, err := s.resolvePresentationLanguage(ctx, filter)
if err != nil || language == "" || sameMetadataLanguage(item.DefaultMetadataLanguage, language) {
return ""
}
loc, err := s.itemLocRepo.Get(ctx, item.ContentID, language)
if err != nil || (loc != nil && loc.Overview != "") {
return ""
}
return language
}
func (s *DetailService) validatePresentationItemAccess(ctx context.Context, filter AccessFilter, contentID string) error {
if filter.PresentationLibraryID == nil {
return nil
@@ -623,17 +654,7 @@ func (s *DetailService) LocalizeItemModel(ctx context.Context, item *models.Medi
if err != nil || loc == nil {
return cloneMediaItem(item), err
}
localized := cloneMediaItem(item)
localized.Title = loc.Title
localized.SortTitle = loc.SortTitle
localized.Overview = loc.Overview
localized.Tagline = loc.Tagline
localized.PosterPath = loc.PosterPath
localized.PosterThumbhash = loc.PosterThumbhash
localized.BackdropPath = loc.BackdropPath
localized.BackdropThumbhash = loc.BackdropThumbhash
localized.LogoPath = loc.LogoPath
return localized, nil
return applyItemLocalization(item, loc), nil
}
func (s *DetailService) LocalizeSeasonModel(ctx context.Context, season *models.Season, filter AccessFilter) (*models.Season, error) {
@@ -648,12 +669,7 @@ func (s *DetailService) LocalizeSeasonModel(ctx context.Context, season *models.
if err != nil || loc == nil {
return cloneSeason(season), err
}
localized := cloneSeason(season)
localized.Title = loc.Title
localized.Overview = loc.Overview
localized.PosterPath = loc.PosterPath
localized.PosterThumbhash = loc.PosterThumbhash
return localized, nil
return applySeasonLocalization(season, loc), nil
}
func (s *DetailService) LocalizeEpisodeModel(ctx context.Context, episode *models.Episode, filter AccessFilter) (*models.Episode, error) {
@@ -668,10 +684,7 @@ func (s *DetailService) LocalizeEpisodeModel(ctx context.Context, episode *model
if err != nil || loc == nil {
return cloneEpisode(episode), err
}
localized := cloneEpisode(episode)
localized.Title = loc.Title
localized.Overview = loc.Overview
return localized, nil
return applyEpisodeLocalization(episode, loc), nil
}
// GetItemDetail retrieves a full item detail with presigned URLs and file versions.
@@ -828,6 +841,7 @@ func (s *DetailService) fetchCredits(ctx context.Context, contentID string) ([]C
}
func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.MediaItem, contentID string, filter AccessFilter) (*ItemDetail, error) {
pendingTranslation := s.PendingTranslationLanguage(ctx, item, filter)
localizedItem, err := s.LocalizeItemModel(ctx, item, filter)
if err != nil {
return nil, fmt.Errorf("localizing item detail: %w", err)
@@ -843,6 +857,7 @@ func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.M
Year: item.Year,
Overview: item.Overview,
Tagline: item.Tagline,
PendingTranslationLanguage: pendingTranslation,
Runtime: item.Runtime,
ContentRating: item.ContentRating,
Genres: item.Genres,
+5 -1
View File
@@ -69,7 +69,11 @@ var SensitiveSettingKeys = map[string]bool{
"mdblist.api_key": true,
"introdb.api_key": true,
// Subtitle AI (on-demand translation) API key.
// Shared AI endpoint API keys (+ legacy subtitle_ai alias the loader still
// falls back to; the legacy row is never renamed because ciphertext is
// GCM-bound to its key).
"ai.api_key": true,
"ai.asr_api_key": true,
"subtitle_ai.api_key": true,
// Recommendations embedding auth (+ legacy openai alias at db_loader L409).
+9 -1
View File
@@ -112,6 +112,7 @@ type UpdateFolderInput struct {
Name *string
Enabled *bool
MetadataLanguage *string
AutoTranslateMetadata *bool
ChapterThumbnailsEnabled *bool
IntroDetectionEnabled *bool
}
@@ -142,7 +143,7 @@ func (r *FolderRepository) Pool() *pgxpool.Pool {
// folderColumns is the list of columns returned by all SELECT queries.
// Kept in one place so scanFolder stays in sync.
const folderColumns = `id, type, name, enabled, metadata_language, chapter_thumbnails_enabled, intro_detection_enabled, poster_path, last_scanned_at,
const folderColumns = `id, type, name, enabled, metadata_language, auto_translate_metadata, chapter_thumbnails_enabled, intro_detection_enabled, poster_path, last_scanned_at,
scan_warning_code, scan_warning_message, scan_warning_at, allow_empty_cleanup_once, sort_order`
// scanFolder scans a single row into a *models.MediaFolder.
@@ -155,6 +156,7 @@ func scanFolder(row pgx.Row) (*models.MediaFolder, error) {
&f.Name,
&f.Enabled,
&f.MetadataLanguage,
&f.AutoTranslateMetadata,
&f.ChapterThumbnailsEnabled,
&f.IntroDetectionEnabled,
&f.PosterPath,
@@ -187,6 +189,7 @@ func scanFolders(rows pgx.Rows) ([]*models.MediaFolder, error) {
&f.Name,
&f.Enabled,
&f.MetadataLanguage,
&f.AutoTranslateMetadata,
&f.ChapterThumbnailsEnabled,
&f.IntroDetectionEnabled,
&f.PosterPath,
@@ -400,6 +403,11 @@ func (r *FolderRepository) Update(ctx context.Context, id int, input UpdateFolde
args = append(args, *input.MetadataLanguage)
argIndex++
}
if input.AutoTranslateMetadata != nil {
setClauses = append(setClauses, fmt.Sprintf("auto_translate_metadata = $%d", argIndex))
args = append(args, *input.AutoTranslateMetadata)
argIndex++
}
if input.ChapterThumbnailsEnabled != nil {
setClauses = append(setClauses, fmt.Sprintf("chapter_thumbnails_enabled = $%d", argIndex))
args = append(args, *input.ChapterThumbnailsEnabled)
+179 -8
View File
@@ -10,6 +10,13 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
)
// The localization tables carry per-field provenance (provider | ai | manual)
// on their AI-writable fields (overview, tagline) so writes from different
// origins can never regress quality: manual beats provider beats ai. The
// precedence is enforced here, in single-statement upserts, so concurrent
// writers (a metadata refresh racing an AI translation job) cannot interleave
// a read-modify-write.
type MediaItemLocalizationRepository struct {
pool *pgxpool.Pool
}
@@ -18,6 +25,11 @@ func NewMediaItemLocalizationRepository(pool *pgxpool.Pool) *MediaItemLocalizati
return &MediaItemLocalizationRepository{pool: pool}
}
// Upsert writes a provider localization. Title/sort-title/artwork fields are
// taken wholesale (provider data is authoritative for them); overview and
// tagline respect provenance — a manual value is never overwritten, and an
// empty incoming value never blanks an existing one (so a provider with no
// translated overview does not erase an AI or manual translation).
func (r *MediaItemLocalizationRepository) Upsert(ctx context.Context, loc *models.MediaItemLocalization) error {
if loc == nil || loc.ContentID == "" || loc.Language == "" {
return fmt.Errorf("invalid media item localization")
@@ -34,8 +46,22 @@ func (r *MediaItemLocalizationRepository) Upsert(ctx context.Context, loc *model
ON CONFLICT (content_id, language) DO UPDATE SET
title = EXCLUDED.title,
sort_title = EXCLUDED.sort_title,
overview = EXCLUDED.overview,
tagline = EXCLUDED.tagline,
overview = CASE
WHEN media_item_localizations.overview_source = 'manual' OR EXCLUDED.overview = ''
THEN media_item_localizations.overview
ELSE EXCLUDED.overview END,
overview_source = CASE
WHEN media_item_localizations.overview_source = 'manual' OR EXCLUDED.overview = ''
THEN media_item_localizations.overview_source
ELSE 'provider' END,
tagline = CASE
WHEN media_item_localizations.tagline_source = 'manual' OR EXCLUDED.tagline = ''
THEN media_item_localizations.tagline
ELSE EXCLUDED.tagline END,
tagline_source = CASE
WHEN media_item_localizations.tagline_source = 'manual' OR EXCLUDED.tagline = ''
THEN media_item_localizations.tagline_source
ELSE 'provider' END,
poster_path = EXCLUDED.poster_path,
poster_thumbhash = EXCLUDED.poster_thumbhash,
backdrop_path = EXCLUDED.backdrop_path,
@@ -50,10 +76,69 @@ func (r *MediaItemLocalizationRepository) Upsert(ctx context.Context, loc *model
return nil
}
// UpsertAITranslation writes AI-translated overview/tagline values. A nil
// pointer leaves that field untouched. Per field, the write lands when the
// existing value is empty or already AI-sourced; force additionally overwrites
// provider values (the admin explicitly asked to re-translate). Manual values
// are never overwritten. Rows created here carry empty titles/artwork — the
// serving layer falls back to the base item for empty localized fields.
func (r *MediaItemLocalizationRepository) UpsertAITranslation(ctx context.Context, contentID, language string, overview, tagline *string, force bool) error {
if contentID == "" || language == "" {
return fmt.Errorf("invalid media item AI localization")
}
if overview == nil && tagline == nil {
return nil
}
_, err := r.pool.Exec(ctx, `
INSERT INTO media_item_localizations (
content_id, language, title, sort_title, overview, tagline,
poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path,
overview_source, tagline_source
) VALUES (
$1, $2, '', '', COALESCE($3, ''), COALESCE($4, ''),
'', '', '', '', '',
CASE WHEN $3::text IS NULL THEN 'provider' ELSE 'ai' END,
CASE WHEN $4::text IS NULL THEN 'provider' ELSE 'ai' END
)
ON CONFLICT (content_id, language) DO UPDATE SET
overview = CASE
WHEN $3::text IS NULL OR media_item_localizations.overview_source = 'manual'
THEN media_item_localizations.overview
WHEN $5 OR media_item_localizations.overview_source = 'ai' OR media_item_localizations.overview = ''
THEN EXCLUDED.overview
ELSE media_item_localizations.overview END,
overview_source = CASE
WHEN $3::text IS NULL OR media_item_localizations.overview_source = 'manual'
THEN media_item_localizations.overview_source
WHEN $5 OR media_item_localizations.overview_source = 'ai' OR media_item_localizations.overview = ''
THEN 'ai'
ELSE media_item_localizations.overview_source END,
tagline = CASE
WHEN $4::text IS NULL OR media_item_localizations.tagline_source = 'manual'
THEN media_item_localizations.tagline
WHEN $5 OR media_item_localizations.tagline_source = 'ai' OR media_item_localizations.tagline = ''
THEN EXCLUDED.tagline
ELSE media_item_localizations.tagline END,
tagline_source = CASE
WHEN $4::text IS NULL OR media_item_localizations.tagline_source = 'manual'
THEN media_item_localizations.tagline_source
WHEN $5 OR media_item_localizations.tagline_source = 'ai' OR media_item_localizations.tagline = ''
THEN 'ai'
ELSE media_item_localizations.tagline_source END,
updated_at = NOW()
`, contentID, language, overview, tagline, force)
if err != nil {
return fmt.Errorf("upserting media item AI localization: %w", err)
}
return nil
}
func (r *MediaItemLocalizationRepository) Get(ctx context.Context, contentID, language string) (*models.MediaItemLocalization, error) {
row := r.pool.QueryRow(ctx, `
SELECT content_id, language, title, sort_title, overview, tagline,
poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path,
overview_source, tagline_source,
created_at, updated_at
FROM media_item_localizations
WHERE content_id = $1 AND language = $2
@@ -69,6 +154,7 @@ func (r *MediaItemLocalizationRepository) GetByContentIDs(ctx context.Context, c
rows, err := r.pool.Query(ctx, `
SELECT content_id, language, title, sort_title, overview, tagline,
poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path,
overview_source, tagline_source,
created_at, updated_at
FROM media_item_localizations
WHERE language = $1 AND content_id = ANY($2)
@@ -98,6 +184,8 @@ func NewSeasonLocalizationRepository(pool *pgxpool.Pool) *SeasonLocalizationRepo
return &SeasonLocalizationRepository{pool: pool}
}
// Upsert writes a provider localization; overview respects provenance (see
// MediaItemLocalizationRepository.Upsert).
func (r *SeasonLocalizationRepository) Upsert(ctx context.Context, loc *models.SeasonLocalization) error {
if loc == nil || loc.SeasonContentID == "" || loc.Language == "" {
return fmt.Errorf("invalid season localization")
@@ -108,7 +196,14 @@ func (r *SeasonLocalizationRepository) Upsert(ctx context.Context, loc *models.S
) VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (season_content_id, language) DO UPDATE SET
title = EXCLUDED.title,
overview = EXCLUDED.overview,
overview = CASE
WHEN season_localizations.overview_source = 'manual' OR EXCLUDED.overview = ''
THEN season_localizations.overview
ELSE EXCLUDED.overview END,
overview_source = CASE
WHEN season_localizations.overview_source = 'manual' OR EXCLUDED.overview = ''
THEN season_localizations.overview_source
ELSE 'provider' END,
poster_path = EXCLUDED.poster_path,
poster_thumbhash = EXCLUDED.poster_thumbhash,
updated_at = NOW()
@@ -119,9 +214,41 @@ func (r *SeasonLocalizationRepository) Upsert(ctx context.Context, loc *models.S
return nil
}
// UpsertAIOverview writes an AI-translated season overview (see
// MediaItemLocalizationRepository.UpsertAITranslation for the precedence).
func (r *SeasonLocalizationRepository) UpsertAIOverview(ctx context.Context, seasonContentID, language, overview string, force bool) error {
if seasonContentID == "" || language == "" {
return fmt.Errorf("invalid season AI localization")
}
_, err := r.pool.Exec(ctx, `
INSERT INTO season_localizations (
season_content_id, language, title, overview, poster_path, poster_thumbhash, overview_source
) VALUES ($1, $2, '', $3, '', '', 'ai')
ON CONFLICT (season_content_id, language) DO UPDATE SET
overview = CASE
WHEN season_localizations.overview_source = 'manual'
THEN season_localizations.overview
WHEN $4 OR season_localizations.overview_source = 'ai' OR season_localizations.overview = ''
THEN EXCLUDED.overview
ELSE season_localizations.overview END,
overview_source = CASE
WHEN season_localizations.overview_source = 'manual'
THEN season_localizations.overview_source
WHEN $4 OR season_localizations.overview_source = 'ai' OR season_localizations.overview = ''
THEN 'ai'
ELSE season_localizations.overview_source END,
updated_at = NOW()
`, seasonContentID, language, overview, force)
if err != nil {
return fmt.Errorf("upserting season AI localization: %w", err)
}
return nil
}
func (r *SeasonLocalizationRepository) Get(ctx context.Context, seasonContentID, language string) (*models.SeasonLocalization, error) {
row := r.pool.QueryRow(ctx, `
SELECT season_content_id, language, title, overview, poster_path, poster_thumbhash, created_at, updated_at
SELECT season_content_id, language, title, overview, poster_path, poster_thumbhash,
overview_source, created_at, updated_at
FROM season_localizations
WHERE season_content_id = $1 AND language = $2
`, seasonContentID, language)
@@ -134,7 +261,8 @@ func (r *SeasonLocalizationRepository) GetBySeasonIDs(ctx context.Context, seaso
return result, nil
}
rows, err := r.pool.Query(ctx, `
SELECT season_content_id, language, title, overview, poster_path, poster_thumbhash, created_at, updated_at
SELECT season_content_id, language, title, overview, poster_path, poster_thumbhash,
overview_source, created_at, updated_at
FROM season_localizations
WHERE language = $1 AND season_content_id = ANY($2)
`, language, seasonIDs)
@@ -163,6 +291,8 @@ func NewEpisodeLocalizationRepository(pool *pgxpool.Pool) *EpisodeLocalizationRe
return &EpisodeLocalizationRepository{pool: pool}
}
// Upsert writes a provider localization; overview respects provenance (see
// MediaItemLocalizationRepository.Upsert).
func (r *EpisodeLocalizationRepository) Upsert(ctx context.Context, loc *models.EpisodeLocalization) error {
if loc == nil || loc.EpisodeContentID == "" || loc.Language == "" {
return fmt.Errorf("invalid episode localization")
@@ -172,7 +302,14 @@ func (r *EpisodeLocalizationRepository) Upsert(ctx context.Context, loc *models.
VALUES ($1, $2, $3, $4)
ON CONFLICT (episode_content_id, language) DO UPDATE SET
title = EXCLUDED.title,
overview = EXCLUDED.overview,
overview = CASE
WHEN episode_localizations.overview_source = 'manual' OR EXCLUDED.overview = ''
THEN episode_localizations.overview
ELSE EXCLUDED.overview END,
overview_source = CASE
WHEN episode_localizations.overview_source = 'manual' OR EXCLUDED.overview = ''
THEN episode_localizations.overview_source
ELSE 'provider' END,
updated_at = NOW()
`, loc.EpisodeContentID, loc.Language, loc.Title, loc.Overview)
if err != nil {
@@ -181,9 +318,39 @@ func (r *EpisodeLocalizationRepository) Upsert(ctx context.Context, loc *models.
return nil
}
// UpsertAIOverview writes an AI-translated episode overview (see
// MediaItemLocalizationRepository.UpsertAITranslation for the precedence).
func (r *EpisodeLocalizationRepository) UpsertAIOverview(ctx context.Context, episodeContentID, language, overview string, force bool) error {
if episodeContentID == "" || language == "" {
return fmt.Errorf("invalid episode AI localization")
}
_, err := r.pool.Exec(ctx, `
INSERT INTO episode_localizations (episode_content_id, language, title, overview, overview_source)
VALUES ($1, $2, '', $3, 'ai')
ON CONFLICT (episode_content_id, language) DO UPDATE SET
overview = CASE
WHEN episode_localizations.overview_source = 'manual'
THEN episode_localizations.overview
WHEN $4 OR episode_localizations.overview_source = 'ai' OR episode_localizations.overview = ''
THEN EXCLUDED.overview
ELSE episode_localizations.overview END,
overview_source = CASE
WHEN episode_localizations.overview_source = 'manual'
THEN episode_localizations.overview_source
WHEN $4 OR episode_localizations.overview_source = 'ai' OR episode_localizations.overview = ''
THEN 'ai'
ELSE episode_localizations.overview_source END,
updated_at = NOW()
`, episodeContentID, language, overview, force)
if err != nil {
return fmt.Errorf("upserting episode AI localization: %w", err)
}
return nil
}
func (r *EpisodeLocalizationRepository) Get(ctx context.Context, episodeContentID, language string) (*models.EpisodeLocalization, error) {
row := r.pool.QueryRow(ctx, `
SELECT episode_content_id, language, title, overview, created_at, updated_at
SELECT episode_content_id, language, title, overview, overview_source, created_at, updated_at
FROM episode_localizations
WHERE episode_content_id = $1 AND language = $2
`, episodeContentID, language)
@@ -196,7 +363,7 @@ func (r *EpisodeLocalizationRepository) GetByEpisodeIDs(ctx context.Context, epi
return result, nil
}
rows, err := r.pool.Query(ctx, `
SELECT episode_content_id, language, title, overview, created_at, updated_at
SELECT episode_content_id, language, title, overview, overview_source, created_at, updated_at
FROM episode_localizations
WHERE language = $1 AND episode_content_id = ANY($2)
`, language, episodeIDs)
@@ -231,6 +398,8 @@ func scanMediaItemLocalization(row pgx.Row) (*models.MediaItemLocalization, erro
&loc.BackdropPath,
&loc.BackdropThumbhash,
&loc.LogoPath,
&loc.OverviewSource,
&loc.TaglineSource,
&loc.CreatedAt,
&loc.UpdatedAt,
); err != nil {
@@ -251,6 +420,7 @@ func scanSeasonLocalization(row pgx.Row) (*models.SeasonLocalization, error) {
&loc.Overview,
&loc.PosterPath,
&loc.PosterThumbhash,
&loc.OverviewSource,
&loc.CreatedAt,
&loc.UpdatedAt,
); err != nil {
@@ -269,6 +439,7 @@ func scanEpisodeLocalization(row pgx.Row) (*models.EpisodeLocalization, error) {
&loc.Language,
&loc.Title,
&loc.Overview,
&loc.OverviewSource,
&loc.CreatedAt,
&loc.UpdatedAt,
); err != nil {
+75
View File
@@ -0,0 +1,75 @@
package catalog
import "github.com/Silo-Server/silo-server/internal/models"
// applyItemLocalization merges a localization onto a clone of item. Only
// non-empty localized fields override the base — localization rows are
// legitimately partial (an AI translation carries only overview/tagline; a
// provider row may lack a tagline or logo), and an empty field must fall back
// to the base value rather than blank it.
func applyItemLocalization(item *models.MediaItem, loc *models.MediaItemLocalization) *models.MediaItem {
localized := cloneMediaItem(item)
if localized == nil || loc == nil {
return localized
}
if loc.Title != "" {
localized.Title = loc.Title
}
if loc.SortTitle != "" {
localized.SortTitle = loc.SortTitle
}
if loc.Overview != "" {
localized.Overview = loc.Overview
}
if loc.Tagline != "" {
localized.Tagline = loc.Tagline
}
if loc.PosterPath != "" {
localized.PosterPath = loc.PosterPath
localized.PosterThumbhash = loc.PosterThumbhash
}
if loc.BackdropPath != "" {
localized.BackdropPath = loc.BackdropPath
localized.BackdropThumbhash = loc.BackdropThumbhash
}
if loc.LogoPath != "" {
localized.LogoPath = loc.LogoPath
}
return localized
}
// applySeasonLocalization merges a localization onto a clone of season; see
// applyItemLocalization for the empty-field semantics.
func applySeasonLocalization(season *models.Season, loc *models.SeasonLocalization) *models.Season {
localized := cloneSeason(season)
if localized == nil || loc == nil {
return localized
}
if loc.Title != "" {
localized.Title = loc.Title
}
if loc.Overview != "" {
localized.Overview = loc.Overview
}
if loc.PosterPath != "" {
localized.PosterPath = loc.PosterPath
localized.PosterThumbhash = loc.PosterThumbhash
}
return localized
}
// applyEpisodeLocalization merges a localization onto a clone of episode; see
// applyItemLocalization for the empty-field semantics.
func applyEpisodeLocalization(episode *models.Episode, loc *models.EpisodeLocalization) *models.Episode {
localized := cloneEpisode(episode)
if localized == nil || loc == nil {
return localized
}
if loc.Title != "" {
localized.Title = loc.Title
}
if loc.Overview != "" {
localized.Overview = loc.Overview
}
return localized
}
+109
View File
@@ -0,0 +1,109 @@
package catalog
import (
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
func baseItem() *models.MediaItem {
return &models.MediaItem{
ContentID: "c1",
Title: "Base Title",
SortTitle: "Base Title, The",
Overview: "Base overview.",
Tagline: "Base tagline.",
PosterPath: "posters/base.jpg",
PosterThumbhash: "hash-poster",
BackdropPath: "backdrops/base.jpg",
BackdropThumbhash: "hash-backdrop",
LogoPath: "logos/base.png",
}
}
// An AI-created localization row carries only overview/tagline; everything
// else must fall back to the base item rather than be blanked.
func TestApplyItemLocalizationPartialRowFallsBackToBase(t *testing.T) {
loc := &models.MediaItemLocalization{
ContentID: "c1",
Language: "fr",
Overview: "Résumé traduit.",
Tagline: "Slogan traduit.",
}
got := applyItemLocalization(baseItem(), loc)
if got.Overview != "Résumé traduit." || got.Tagline != "Slogan traduit." {
t.Errorf("translated fields not applied: %q / %q", got.Overview, got.Tagline)
}
if got.Title != "Base Title" || got.SortTitle != "Base Title, The" {
t.Errorf("empty localized titles blanked the base: %q / %q", got.Title, got.SortTitle)
}
if got.PosterPath != "posters/base.jpg" || got.PosterThumbhash != "hash-poster" {
t.Errorf("empty localized poster blanked the base: %q / %q", got.PosterPath, got.PosterThumbhash)
}
if got.BackdropPath != "backdrops/base.jpg" || got.LogoPath != "logos/base.png" {
t.Errorf("empty localized artwork blanked the base: %q / %q", got.BackdropPath, got.LogoPath)
}
}
func TestApplyItemLocalizationFullRowOverridesEverything(t *testing.T) {
loc := &models.MediaItemLocalization{
ContentID: "c1",
Language: "fr",
Title: "Titre",
SortTitle: "Titre, Le",
Overview: "Résumé.",
Tagline: "Slogan.",
PosterPath: "posters/fr.jpg",
PosterThumbhash: "hash-fr",
BackdropPath: "backdrops/fr.jpg",
BackdropThumbhash: "hash-fr-bd",
LogoPath: "logos/fr.png",
}
got := applyItemLocalization(baseItem(), loc)
if got.Title != "Titre" || got.Overview != "Résumé." || got.PosterPath != "posters/fr.jpg" ||
got.PosterThumbhash != "hash-fr" || got.LogoPath != "logos/fr.png" {
t.Errorf("full localization not applied: %+v", got)
}
}
func TestApplyItemLocalizationDoesNotMutateBase(t *testing.T) {
item := baseItem()
_ = applyItemLocalization(item, &models.MediaItemLocalization{Title: "Titre"})
if item.Title != "Base Title" {
t.Errorf("base item mutated: %q", item.Title)
}
}
func TestApplyItemLocalizationNilLocalizationClones(t *testing.T) {
item := baseItem()
got := applyItemLocalization(item, nil)
if got == item {
t.Fatal("expected a clone, got the same pointer")
}
if got.Title != item.Title {
t.Errorf("clone differs: %q", got.Title)
}
}
func TestApplySeasonLocalizationPartialRow(t *testing.T) {
season := &models.Season{ContentID: "s1", Title: "Season 1", Overview: "Base.", PosterPath: "p.jpg", PosterThumbhash: "h"}
got := applySeasonLocalization(season, &models.SeasonLocalization{Overview: "Saison résumé."})
if got.Overview != "Saison résumé." {
t.Errorf("overview not applied: %q", got.Overview)
}
if got.Title != "Season 1" || got.PosterPath != "p.jpg" || got.PosterThumbhash != "h" {
t.Errorf("empty fields blanked the base: %+v", got)
}
}
func TestApplyEpisodeLocalizationPartialRow(t *testing.T) {
ep := &models.Episode{ContentID: "e1", Title: "Pilot", Overview: "Base."}
got := applyEpisodeLocalization(ep, &models.EpisodeLocalization{Overview: "Épisode résumé."})
if got.Overview != "Épisode résumé." {
t.Errorf("overview not applied: %q", got.Overview)
}
if got.Title != "Pilot" {
t.Errorf("empty localized title blanked the base: %q", got.Title)
}
}
+36 -9
View File
@@ -246,18 +246,43 @@ type RecommendationsConfig struct {
CowatchCron string `yaml:"-"`
}
// SubtitleAIConfig holds settings for on-demand AI subtitle translation (and,
// in a follow-up, Whisper ASR generation) via a single OpenAI-compatible
// endpoint — the operator can point it at OpenAI, Groq, a local Ollama server,
// etc. Mirrors the recommendations embedding client's configuration style.
type SubtitleAIConfig struct {
Enabled bool `yaml:"-"`
// AIConfig holds the shared connection settings for Silo's AI features
// (subtitle translation, metadata translation, Whisper ASR): one
// OpenAI-compatible endpoint the operator can point at OpenAI, Groq, a local
// Ollama/llama.cpp server, etc., with an optional separate endpoint for audio
// transcription (operators often run a dedicated Whisper server). Settings
// load from the ai.* keys, falling back to the legacy subtitle_ai.* rows.
type AIConfig struct {
BaseURL string `yaml:"-"`
APIKey string `yaml:"-"`
ChatModel string `yaml:"-"`
MaxConcurrentJobs int `yaml:"-"`
BatchSize int `yaml:"-"`
ContextNeighbors int `yaml:"-"`
ASRBaseURL string `yaml:"-"` // empty = BaseURL
ASRAPIKey string `yaml:"-"` // empty = APIKey
ASRModel string `yaml:"-"`
MaxConcurrentJobs int `yaml:"-"` // one shared bound across all AI job services
}
// SubtitleAIConfig holds the subtitle-AI feature toggles and tuning. The
// endpoint connection lives in AIConfig.
type SubtitleAIConfig struct {
Enabled bool `yaml:"-"` // on-demand translation
TranscribeEnabled bool `yaml:"-"` // Whisper ASR generation
BatchSize int `yaml:"-"`
ContextNeighbors int `yaml:"-"`
// ASRChunkSeconds is the audio chunk length per transcription request
// (60..600). Shorter chunks bound Whisper timestamp drift on long files;
// longer chunks mean fewer requests and fewer boundary word-clips.
ASRChunkSeconds int `yaml:"-"`
}
// MetadataAIConfig holds the metadata translation feature toggles. The
// endpoint connection lives in AIConfig.
type MetadataAIConfig struct {
Enabled bool `yaml:"-"`
// OnView controls viewer-triggered description translation on detail
// pages: "off" (default), "button" (explicit chip), or "auto" (translate
// on view with a loading animation).
OnView string `yaml:"-"`
}
// DownloadConfig holds server-wide download policy settings.
@@ -291,7 +316,9 @@ type Config struct {
JellyfinCompat JellyfinCompatConfig `yaml:"-"`
AudiobookshelfCompat AudiobookshelfCompatConfig `yaml:"-"`
Recommendations RecommendationsConfig `yaml:"-"`
AI AIConfig `yaml:"-"`
SubtitleAI SubtitleAIConfig `yaml:"-"`
MetadataAI MetadataAIConfig `yaml:"-"`
Download DownloadConfig `yaml:"-"`
TMDBAPIKey string `yaml:"-"`
MDBListAPIKey string `yaml:"-"`
+45 -6
View File
@@ -2,6 +2,7 @@ package config
import (
"fmt"
"log/slog"
"strconv"
"time"
@@ -422,20 +423,38 @@ func LoadFromDB(m map[string]string) (*Config, error) {
cfg.Recommendations.DiversityLambda = diversityLambda
cfg.Recommendations.CowatchCron = stringOr(m, "recommendations.cowatch_cron", "30 4 * * *")
// Subtitle AI (on-demand translation; Whisper ASR generation in a follow-up)
// Shared AI endpoint (subtitle translation, metadata translation, Whisper
// ASR). The connection settings read ai.* with a fallback to the legacy
// subtitle_ai.* rows; the legacy rows are never renamed in SQL because
// encrypted values are GCM-bound to their setting key.
cfg.AI.BaseURL = stringOr(m, "ai.base_url", stringOr(m, "subtitle_ai.base_url", "https://api.openai.com"))
cfg.AI.APIKey = stringOr(m, "ai.api_key", stringOr(m, "subtitle_ai.api_key", ""))
cfg.AI.ChatModel = stringOr(m, "ai.chat_model", stringOr(m, "subtitle_ai.chat_model", "gpt-4o-mini"))
cfg.AI.ASRBaseURL = stringOr(m, "ai.asr_base_url", "")
cfg.AI.ASRAPIKey = stringOr(m, "ai.asr_api_key", "")
cfg.AI.ASRModel = stringOr(m, "ai.asr_model", "whisper-1")
aiMaxConcurrent, err := intOr(m, "ai.max_concurrent_jobs", 0)
if err != nil {
return nil, err
}
if aiMaxConcurrent <= 0 {
if aiMaxConcurrent, err = intOr(m, "subtitle_ai.max_concurrent_jobs", 2); err != nil {
return nil, err
}
}
cfg.AI.MaxConcurrentJobs = aiMaxConcurrent
// Subtitle AI feature toggles and tuning.
subtitleAIEnabled, err := boolOr(m, "subtitle_ai.enabled", false)
if err != nil {
return nil, err
}
cfg.SubtitleAI.Enabled = subtitleAIEnabled
cfg.SubtitleAI.BaseURL = stringOr(m, "subtitle_ai.base_url", "https://api.openai.com")
cfg.SubtitleAI.APIKey = stringOr(m, "subtitle_ai.api_key", "")
cfg.SubtitleAI.ChatModel = stringOr(m, "subtitle_ai.chat_model", "gpt-4o-mini")
subtitleAIMaxConcurrent, err := intOr(m, "subtitle_ai.max_concurrent_jobs", 2)
subtitleAITranscribe, err := boolOr(m, "subtitle_ai.transcribe_enabled", false)
if err != nil {
return nil, err
}
cfg.SubtitleAI.MaxConcurrentJobs = subtitleAIMaxConcurrent
cfg.SubtitleAI.TranscribeEnabled = subtitleAITranscribe
subtitleAIBatchSize, err := intOr(m, "subtitle_ai.batch_size", 40)
if err != nil {
return nil, err
@@ -446,6 +465,26 @@ func LoadFromDB(m map[string]string) (*Config, error) {
return nil, err
}
cfg.SubtitleAI.ContextNeighbors = subtitleAIContextNeighbors
subtitleAIChunkSeconds, err := intOr(m, "subtitle_ai.asr_chunk_seconds", 600)
if err != nil {
return nil, err
}
cfg.SubtitleAI.ASRChunkSeconds = subtitleAIChunkSeconds
// Metadata AI translation feature toggles.
metadataAIEnabled, err := boolOr(m, "metadata_ai.enabled", false)
if err != nil {
return nil, err
}
cfg.MetadataAI.Enabled = metadataAIEnabled
switch onView := stringOr(m, "metadata_ai.on_view", "off"); onView {
case "off", "button", "auto":
cfg.MetadataAI.OnView = onView
default:
// A bad row must not block startup; the feature just stays off.
slog.Warn("invalid metadata_ai.on_view setting; using off", "value", onView)
cfg.MetadataAI.OnView = "off"
}
// Download
downloadEnabled, err := boolOr(m, "download.enabled", false)
+7 -6
View File
@@ -43,12 +43,13 @@ func NewScopeAccessFilter(resolver ScopeResolver) AccessFilterResolver {
}
}
return catalog.AccessFilter{
AllowedLibraryIDs: scope.AllowedLibraryIDs,
DisabledLibraryIDs: scope.DisabledLibraryIDs,
MaxContentRating: scope.MaxContentRating,
MaxPlaybackQuality: scope.MaxPlaybackQuality,
UserID: userID,
ProfileID: profileID,
AllowedLibraryIDs: scope.AllowedLibraryIDs,
DisabledLibraryIDs: scope.DisabledLibraryIDs,
MaxContentRating: scope.MaxContentRating,
MaxPlaybackQuality: scope.MaxPlaybackQuality,
ProfilePreferredLanguage: scope.PreferredMetadataLanguage,
UserID: userID,
ProfileID: profileID,
}
}
}
+44
View File
@@ -174,6 +174,14 @@ type metadataFolderRepo interface {
GetByID(ctx context.Context, id int) (*models.MediaFolder, error)
}
// AutoTranslator is the seam to the metadata AI translation service: after a
// refresh, libraries that opted in get missing localizations filled by AI.
// Implemented by *translation.Service; AutoEnqueue must be cheap and must
// never fail the refresh (it logs its own errors).
type AutoTranslator interface {
AutoEnqueue(ctx context.Context, itemContentID, language string)
}
type metadataContentFileLister interface {
GetByContentID(ctx context.Context, contentID string) ([]*models.MediaFile, error)
}
@@ -280,6 +288,7 @@ type MetadataService struct {
itemLocalizationRepo *catalog.MediaItemLocalizationRepository
seasonLocalizationRepo *catalog.SeasonLocalizationRepository
episodeLocalizationRepo *catalog.EpisodeLocalizationRepository
autoTranslator AutoTranslator // optional; set via SetAutoTranslator
personRepo *catalog.PersonRepository
fileRepo FileContentUpdater
skippedRootRepo metadataSkippedRootRepo
@@ -414,6 +423,12 @@ func NewMetadataService(
}
// SetImageCacher enables S3 image caching during metadata persistence.
// SetAutoTranslator wires the metadata AI translation fallback. Optional;
// without it, refreshes simply skip the auto-translate hook.
func (s *MetadataService) SetAutoTranslator(t AutoTranslator) {
s.autoTranslator = t
}
func (s *MetadataService) SetImageCacher(c ImageCacher) {
s.imageCacher = c
}
@@ -514,9 +529,38 @@ func (s *MetadataService) Process(ctx context.Context, req ProcessRequest) (*Pro
final.Updated = final.Updated || result.Updated
}
s.maybeAutoTranslate(ctx, folderID, final.ContentID)
return &final, nil
}
// maybeAutoTranslate queues an AI translation for libraries that opted in,
// when the item's default metadata language differs from the library's. The
// translation service itself checks whether anything is actually missing, so
// this fires cheaply on every refresh. Runs in the background — a refresh
// never waits on (or fails because of) the translation seam.
func (s *MetadataService) maybeAutoTranslate(ctx context.Context, folderID int, contentID string) {
if s.autoTranslator == nil || contentID == "" || folderID <= 0 || s.folderRepo == nil || s.itemRepo == nil {
return
}
folder, err := s.folderRepo.GetByID(ctx, folderID)
if err != nil || folder == nil || !folder.AutoTranslateMetadata {
return
}
library := strings.TrimSpace(folder.MetadataLanguage)
if library == "" {
return
}
item, err := s.itemRepo.GetByID(ctx, contentID)
if err != nil || item == nil {
return
}
if strings.EqualFold(strings.TrimSpace(item.DefaultMetadataLanguage), library) {
return
}
go s.autoTranslator.AutoEnqueue(context.WithoutCancel(ctx), contentID, library)
}
func parseProcessFolderID(raw string) int {
folderID := 0
if raw != "" {
+139
View File
@@ -0,0 +1,139 @@
// Package translation translates catalog descriptions (overviews, taglines)
// into the localization tables with an OpenAI-compatible chat model, via the
// shared AI core. Jobs are triggered manually from the metadata editor or
// automatically after a metadata refresh when the library opts in and the
// providers had no localization for its language. Results are ordinary
// localization rows (marked source='ai'), so every client receives them
// through the existing localized detail responses with no client changes.
package translation
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/Silo-Server/silo-server/internal/ai/jobrunner"
)
var (
// ErrNotConfigured is returned when metadata translation is requested but
// the feature is disabled or the AI endpoint is missing required settings.
ErrNotConfigured = errors.New("metadata AI translation is not configured")
// ErrInvalidRequest wraps caller-input validation failures so handlers can
// map them to 400 rather than 500.
ErrInvalidRequest = errors.New("invalid metadata translation request")
// ErrJobNotFound is returned for unknown job IDs.
ErrJobNotFound = errors.New("metadata translation job not found")
)
// TargetKind identifies what a job translates.
type TargetKind string
const (
// TargetItem covers a movie or series; for series, IncludeChildren expands
// the job to every season and episode overview.
TargetItem TargetKind = "item"
TargetSeason TargetKind = "season"
TargetEpisode TargetKind = "episode"
)
// JobStatus is the lifecycle state of a job, shared with the other AI job
// services via jobrunner.
type JobStatus = jobrunner.Status
// Job is a persisted metadata translation job. It is serialized to the API
// as-is.
type Job struct {
ID int64 `json:"id"`
TargetKind TargetKind `json:"target_kind"`
ContentID string `json:"content_id"`
IncludeChildren bool `json:"include_children"`
SourceLanguage string `json:"source_language"`
TargetLanguage string `json:"target_language"`
Engine string `json:"engine"`
Model string `json:"model"`
Status JobStatus `json:"status"`
Progress float64 `json:"progress"`
ProgressMessage string `json:"progress_message"`
FieldsDone int `json:"fields_done"`
FieldsTotal int `json:"fields_total"`
Force bool `json:"force"`
ErrorMessage string `json:"error_message,omitempty"`
IdempotencyKey string `json:"-"`
RequestedBy *int `json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
HeartbeatAt time.Time `json:"-"`
}
// JobRequest is the input to Service.Enqueue.
type JobRequest struct {
TargetKind TargetKind
ContentID string
TargetLanguage string
IncludeChildren bool
// Force re-translates fields that already have a provider or AI value.
// Manual values are never overwritten regardless.
Force bool
RequestedBy *int
}
// JobRepository persists metadata translation jobs.
type JobRepository interface {
InsertJob(ctx context.Context, job *Job) error
GetJob(ctx context.Context, id int64) (*Job, error)
// GetActiveJobByIdempotencyKey returns a pending/running job with the given
// key, or nil if none exists.
GetActiveJobByIdempotencyKey(ctx context.Context, key string) (*Job, error)
ListJobsByContent(ctx context.Context, contentID string) ([]Job, error)
UpdateProgress(ctx context.Context, id int64, status JobStatus, progress float64, message string, fieldsDone, fieldsTotal int) error
CompleteJob(ctx context.Context, id int64, message string, fieldsDone, fieldsTotal int) error
FailJob(ctx context.Context, id int64, status JobStatus, message string) error
Heartbeat(ctx context.Context, id int64) error
ResetStaleJobs(ctx context.Context, before time.Time, message string) (int64, error)
}
// ItemText is the translatable base-row text of a movie or series.
type ItemText struct {
ContentID string
Type string
Title string
Year int
Overview string
Tagline string
DefaultLanguage string
}
// ChildText is the translatable base-row text of a season or episode.
// EpisodeNumber is zero for seasons.
type ChildText struct {
ContentID string
SeasonNumber int
EpisodeNumber int
Overview string
}
// ContentReader loads the base-row text a job translates. Implementations
// return nil (not an error) for missing rows.
type ContentReader interface {
ItemText(ctx context.Context, contentID string) (*ItemText, error)
SeasonTexts(ctx context.Context, seriesID string) ([]ChildText, error)
EpisodeTexts(ctx context.Context, seriesID string) ([]ChildText, error)
// SeasonByID / EpisodeByID also return the parent series content ID.
SeasonByID(ctx context.Context, contentID string) (*ChildText, string, error)
EpisodeByID(ctx context.Context, contentID string) (*ChildText, string, error)
// CountMissingFields counts base fields (item overview/tagline, season and
// episode overviews) that have source text but no localized value for the
// language — the auto-translate trigger condition.
CountMissingFields(ctx context.Context, itemContentID, language string) (int, error)
}
// idempotencyKey derives the dedup key for a job. Two requests for the same
// target, language, and model collapse to one in-flight job.
func idempotencyKey(kind TargetKind, contentID, targetLang, model string) string {
sum := sha256.Sum256(fmt.Appendf(nil, "%s|%s|%s|%s", kind, contentID, targetLang, model))
return hex.EncodeToString(sum[:])
}
+279
View File
@@ -0,0 +1,279 @@
package translation
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/ai/jobrunner"
)
// PgRepository implements JobRepository and ContentReader on PostgreSQL.
type PgRepository struct {
pool *pgxpool.Pool
}
// NewPgRepository creates a Postgres-backed repository.
func NewPgRepository(pool *pgxpool.Pool) *PgRepository {
return &PgRepository{pool: pool}
}
const jobColumns = `id, target_kind, content_id, include_children, source_language, target_language,
engine, model, status, progress, progress_message, fields_done, fields_total, force,
error_message, idempotency_key, requested_by, created_at, updated_at, heartbeat_at`
func scanJob(row pgx.Row) (*Job, error) {
var j Job
err := row.Scan(
&j.ID, &j.TargetKind, &j.ContentID, &j.IncludeChildren, &j.SourceLanguage, &j.TargetLanguage,
&j.Engine, &j.Model, &j.Status, &j.Progress, &j.ProgressMessage, &j.FieldsDone, &j.FieldsTotal, &j.Force,
&j.ErrorMessage, &j.IdempotencyKey, &j.RequestedBy, &j.CreatedAt, &j.UpdatedAt, &j.HeartbeatAt,
)
if err != nil {
return nil, err
}
return &j, nil
}
func (r *PgRepository) InsertJob(ctx context.Context, job *Job) error {
if job.Engine == "" {
job.Engine = "openai"
}
if job.Status == "" {
job.Status = jobrunner.StatusPending
}
return r.pool.QueryRow(ctx,
`INSERT INTO metadata_translation_jobs
(target_kind, content_id, include_children, source_language, target_language,
engine, model, status, progress, progress_message, force, idempotency_key, requested_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id, created_at, updated_at, heartbeat_at`,
job.TargetKind, job.ContentID, job.IncludeChildren, job.SourceLanguage, job.TargetLanguage,
job.Engine, job.Model, job.Status, job.Progress, job.ProgressMessage, job.Force, job.IdempotencyKey, job.RequestedBy,
).Scan(&job.ID, &job.CreatedAt, &job.UpdatedAt, &job.HeartbeatAt)
}
func (r *PgRepository) GetJob(ctx context.Context, id int64) (*Job, error) {
job, err := scanJob(r.pool.QueryRow(ctx,
`SELECT `+jobColumns+` FROM metadata_translation_jobs WHERE id = $1`, id))
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get metadata translation job: %w", err)
}
return job, nil
}
func (r *PgRepository) GetActiveJobByIdempotencyKey(ctx context.Context, key string) (*Job, error) {
job, err := scanJob(r.pool.QueryRow(ctx,
`SELECT `+jobColumns+` FROM metadata_translation_jobs
WHERE idempotency_key = $1 AND status IN ('pending', 'running')
ORDER BY created_at DESC LIMIT 1`, key))
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get active metadata translation job: %w", err)
}
return job, nil
}
func (r *PgRepository) ListJobsByContent(ctx context.Context, contentID string) ([]Job, error) {
rows, err := r.pool.Query(ctx,
`SELECT `+jobColumns+` FROM metadata_translation_jobs
WHERE content_id = $1 ORDER BY created_at DESC LIMIT 50`, contentID)
if err != nil {
return nil, fmt.Errorf("list metadata translation jobs: %w", err)
}
defer rows.Close()
var jobs []Job
for rows.Next() {
job, err := scanJob(rows)
if err != nil {
return nil, fmt.Errorf("scan metadata translation job: %w", err)
}
jobs = append(jobs, *job)
}
return jobs, rows.Err()
}
// UpdateProgress, CompleteJob, and FailJob only transition a job that is still
// active ("pending"/"running"). The guard makes them no-ops on an already
// terminal row, so a job that was cancelled or reaped as stale can never be
// resurrected by a late write from its own worker goroutine.
func (r *PgRepository) UpdateProgress(ctx context.Context, id int64, status JobStatus, progress float64, message string, fieldsDone, fieldsTotal int) error {
_, err := r.pool.Exec(ctx,
`UPDATE metadata_translation_jobs
SET status = $2, progress = $3, progress_message = $4, fields_done = $5, fields_total = $6,
updated_at = now(), heartbeat_at = now()
WHERE id = $1 AND status IN ('pending', 'running')`, id, status, progress, message, fieldsDone, fieldsTotal)
if err != nil {
return fmt.Errorf("update metadata translation job progress: %w", err)
}
return nil
}
func (r *PgRepository) CompleteJob(ctx context.Context, id int64, message string, fieldsDone, fieldsTotal int) error {
_, err := r.pool.Exec(ctx,
`UPDATE metadata_translation_jobs
SET status = 'completed', progress = 1, progress_message = $2, fields_done = $3, fields_total = $4,
error_message = '', updated_at = now(), heartbeat_at = now()
WHERE id = $1 AND status IN ('pending', 'running')`, id, message, fieldsDone, fieldsTotal)
if err != nil {
return fmt.Errorf("complete metadata translation job: %w", err)
}
return nil
}
func (r *PgRepository) FailJob(ctx context.Context, id int64, status JobStatus, message string) error {
_, err := r.pool.Exec(ctx,
`UPDATE metadata_translation_jobs
SET status = $2, error_message = $3, updated_at = now(), heartbeat_at = now()
WHERE id = $1 AND status IN ('pending', 'running')`, id, status, message)
if err != nil {
return fmt.Errorf("fail metadata translation job: %w", err)
}
return nil
}
func (r *PgRepository) Heartbeat(ctx context.Context, id int64) error {
_, err := r.pool.Exec(ctx,
`UPDATE metadata_translation_jobs SET heartbeat_at = now() WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("heartbeat metadata translation job: %w", err)
}
return nil
}
func (r *PgRepository) ResetStaleJobs(ctx context.Context, before time.Time, message string) (int64, error) {
tag, err := r.pool.Exec(ctx,
`UPDATE metadata_translation_jobs
SET status = 'failed', error_message = $1, updated_at = now()
WHERE status IN ('pending', 'running') AND heartbeat_at < $2`, message, before)
if err != nil {
return 0, fmt.Errorf("reset stale metadata translation jobs: %w", err)
}
return tag.RowsAffected(), nil
}
func (r *PgRepository) ItemText(ctx context.Context, contentID string) (*ItemText, error) {
var item ItemText
err := r.pool.QueryRow(ctx,
`SELECT content_id, type, title, COALESCE(year, 0), COALESCE(overview, ''), COALESCE(tagline, ''),
COALESCE(default_metadata_language, '')
FROM media_items WHERE content_id = $1`, contentID).
Scan(&item.ContentID, &item.Type, &item.Title, &item.Year, &item.Overview, &item.Tagline, &item.DefaultLanguage)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("load item text: %w", err)
}
return &item, nil
}
func (r *PgRepository) SeasonTexts(ctx context.Context, seriesID string) ([]ChildText, error) {
rows, err := r.pool.Query(ctx,
`SELECT content_id, season_number, COALESCE(overview, '')
FROM seasons WHERE series_id = $1 ORDER BY season_number`, seriesID)
if err != nil {
return nil, fmt.Errorf("load season texts: %w", err)
}
defer rows.Close()
var out []ChildText
for rows.Next() {
var c ChildText
if err := rows.Scan(&c.ContentID, &c.SeasonNumber, &c.Overview); err != nil {
return nil, fmt.Errorf("scan season text: %w", err)
}
out = append(out, c)
}
return out, rows.Err()
}
func (r *PgRepository) EpisodeTexts(ctx context.Context, seriesID string) ([]ChildText, error) {
rows, err := r.pool.Query(ctx,
`SELECT content_id, season_number, episode_number, COALESCE(overview, '')
FROM episodes WHERE series_id = $1 ORDER BY season_number, episode_number`, seriesID)
if err != nil {
return nil, fmt.Errorf("load episode texts: %w", err)
}
defer rows.Close()
var out []ChildText
for rows.Next() {
var c ChildText
if err := rows.Scan(&c.ContentID, &c.SeasonNumber, &c.EpisodeNumber, &c.Overview); err != nil {
return nil, fmt.Errorf("scan episode text: %w", err)
}
out = append(out, c)
}
return out, rows.Err()
}
func (r *PgRepository) SeasonByID(ctx context.Context, contentID string) (*ChildText, string, error) {
var c ChildText
var seriesID string
err := r.pool.QueryRow(ctx,
`SELECT content_id, series_id, season_number, COALESCE(overview, '')
FROM seasons WHERE content_id = $1`, contentID).
Scan(&c.ContentID, &seriesID, &c.SeasonNumber, &c.Overview)
if errors.Is(err, pgx.ErrNoRows) {
return nil, "", nil
}
if err != nil {
return nil, "", fmt.Errorf("load season: %w", err)
}
return &c, seriesID, nil
}
func (r *PgRepository) EpisodeByID(ctx context.Context, contentID string) (*ChildText, string, error) {
var c ChildText
var seriesID string
err := r.pool.QueryRow(ctx,
`SELECT content_id, series_id, season_number, episode_number, COALESCE(overview, '')
FROM episodes WHERE content_id = $1`, contentID).
Scan(&c.ContentID, &seriesID, &c.SeasonNumber, &c.EpisodeNumber, &c.Overview)
if errors.Is(err, pgx.ErrNoRows) {
return nil, "", nil
}
if err != nil {
return nil, "", fmt.Errorf("load episode: %w", err)
}
return &c, seriesID, nil
}
// CountMissingFields counts base fields with source text but no localized
// value for the language. One round-trip; used to gate the auto-translate
// enqueue so repeat refreshes of fully translated items are free.
func (r *PgRepository) CountMissingFields(ctx context.Context, itemContentID, language string) (int, error) {
var missing int
err := r.pool.QueryRow(ctx, `
SELECT
(SELECT count(*) FROM media_items mi
LEFT JOIN media_item_localizations l ON l.content_id = mi.content_id AND l.language = $2
WHERE mi.content_id = $1
AND COALESCE(mi.overview, '') <> '' AND COALESCE(l.overview, '') = '')
+ (SELECT count(*) FROM media_items mi
LEFT JOIN media_item_localizations l ON l.content_id = mi.content_id AND l.language = $2
WHERE mi.content_id = $1
AND COALESCE(mi.tagline, '') <> '' AND COALESCE(l.tagline, '') = '')
+ (SELECT count(*) FROM seasons s
LEFT JOIN season_localizations sl ON sl.season_content_id = s.content_id AND sl.language = $2
WHERE s.series_id = $1
AND COALESCE(s.overview, '') <> '' AND COALESCE(sl.overview, '') = '')
+ (SELECT count(*) FROM episodes e
LEFT JOIN episode_localizations el ON el.episode_content_id = e.content_id AND el.language = $2
WHERE e.series_id = $1
AND COALESCE(e.overview, '') <> '' AND COALESCE(el.overview, '') = '')
`, itemContentID, language).Scan(&missing)
if err != nil {
return 0, fmt.Errorf("count missing localization fields: %w", err)
}
return missing, nil
}
+34
View File
@@ -0,0 +1,34 @@
package translation
import "fmt"
// metadataBatchSize is how many fields are translated per chat request.
// Smaller than the subtitle batch size — these are paragraphs, not cue lines —
// while still batching a series' episode overviews together so the model keeps
// terminology consistent across them.
const metadataBatchSize = 10
// systemPrompt builds the translation system prompt. title/year ground the
// model in which work the descriptions belong to so names and references stay
// untranslated and consistent.
func systemPrompt(srcName, tgtName, title string, year int) string {
src := srcName
if src == "" {
src = "the source language"
}
work := title
if year > 0 {
work = fmt.Sprintf("%s (%d)", title, year)
}
return fmt.Sprintf(
"You are a professional translator for a media catalog. Translate plot summaries and taglines "+
"for %q from %s into %s. Produce natural, idiomatic %s that preserves meaning and tone, keeps "+
"proper nouns, character names, and place names as they are conventionally rendered in %s, and "+
"adds no information that is not in the source. "+
"You receive a JSON object whose keys are entry numbers and whose values are the source text. "+
"Respond with ONLY a JSON object using the exact same keys, where each value is the translation "+
"of that entry. Do not add, remove, merge, or renumber entries, and do not output anything "+
"except the JSON object.",
work, src, tgtName, tgtName, tgtName,
)
}
+593
View File
@@ -0,0 +1,593 @@
package translation
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"github.com/Silo-Server/silo-server/internal/ai/jobrunner"
"github.com/Silo-Server/silo-server/internal/ai/llm"
aitranslate "github.com/Silo-Server/silo-server/internal/ai/translate"
"github.com/Silo-Server/silo-server/internal/subtitles"
)
// Config holds the runtime configuration for metadata translation. The shared
// endpoint connection lives in the llm client this service is wired with.
type Config struct {
Enabled bool
Configured bool // shared AI endpoint has a base URL
ChatModel string // for job provenance and idempotency
// OnView controls viewer-triggered translation: "off" | "button" | "auto".
OnView string
}
// Ready reports whether metadata translation can currently run.
func (c Config) Ready() bool { return c.Enabled && c.Configured && c.ChatModel != "" }
// OnViewMode reports the viewer-triggered translation mode ("off" when the
// feature itself is not ready).
func (c Config) OnViewMode() string {
if !c.Ready() || c.OnView == "" {
return "off"
}
return c.OnView
}
// onViewFailureCooldown suppresses re-enqueueing after a failed job for the
// same target+language, so a broken endpoint is not retried on every page
// view. Admin-triggered jobs are unaffected.
const onViewFailureCooldown = 15 * time.Minute
// Service owns the metadata translation job semantics: enqueue validation,
// field collection with skip-if-filled, batched translation, and
// provenance-aware persistence. Lifecycle mechanics are delegated to the
// shared jobrunner.
type Service struct {
cfg Config
repo JobRepository
content ContentReader
locs LocalizationStore
chat aitranslate.ChatFn
runner *jobrunner.Runner
logger *slog.Logger
}
// NewService wires a metadata translation service. sem is the dispatch
// semaphore shared with the other AI job services.
func NewService(
appCtx context.Context,
cfg Config,
repo JobRepository,
content ContentReader,
locs LocalizationStore,
chat aitranslate.ChatFn,
sem chan struct{},
logger *slog.Logger,
) *Service {
if logger == nil {
logger = slog.Default()
}
return &Service{
cfg: cfg,
repo: repo,
content: content,
locs: locs,
chat: chat,
runner: jobrunner.New(appCtx, sem, repo, "metadata translation", logger),
logger: logger,
}
}
// Enabled reports whether metadata translation can currently run.
func (s *Service) Enabled() bool { return s.cfg.Ready() }
// Recover clears jobs orphaned by a crashed worker and starts a background
// reaper that keeps doing so. Call once at startup.
func (s *Service) Recover() { s.runner.Recover() }
// Enqueue validates and queues a job, returning immediately. If an identical
// job is already pending or running, that job is returned instead of a new one.
func (s *Service) Enqueue(ctx context.Context, req JobRequest) (*Job, error) {
if !s.cfg.Ready() {
return nil, ErrNotConfigured
}
if req.TargetKind == "" {
req.TargetKind = TargetItem
}
switch req.TargetKind {
case TargetItem, TargetSeason, TargetEpisode:
default:
return nil, fmt.Errorf("%w: unsupported target kind %q", ErrInvalidRequest, req.TargetKind)
}
if strings.TrimSpace(req.ContentID) == "" {
return nil, fmt.Errorf("%w: content id is required", ErrInvalidRequest)
}
target, err := subtitles.NormalizeLanguageCode(req.TargetLanguage)
if err != nil {
return nil, fmt.Errorf("%w: invalid target language %q", ErrInvalidRequest, req.TargetLanguage)
}
req.TargetLanguage = target
key := idempotencyKey(req.TargetKind, req.ContentID, req.TargetLanguage, s.cfg.ChatModel)
if existing, err := s.repo.GetActiveJobByIdempotencyKey(ctx, key); err != nil {
return nil, err
} else if existing != nil {
return existing, nil
}
job := &Job{
TargetKind: req.TargetKind,
ContentID: req.ContentID,
IncludeChildren: req.IncludeChildren,
TargetLanguage: req.TargetLanguage,
Engine: "openai",
Model: s.cfg.ChatModel,
Status: jobrunner.StatusPending,
ProgressMessage: "Queued",
Force: req.Force,
IdempotencyKey: key,
RequestedBy: req.RequestedBy,
}
if err := s.repo.InsertJob(ctx, job); err != nil {
// A racing duplicate trips the partial unique index; return the winner.
if existing, lookupErr := s.repo.GetActiveJobByIdempotencyKey(ctx, key); lookupErr == nil && existing != nil {
return existing, nil
}
return nil, err
}
s.dispatch(*job)
return job, nil
}
// AutoEnqueue is the metadata-refresh fallback hook: when the library opted
// in and the providers left translatable fields without a localization for
// language, it queues a non-force item job covering the children. Cheap on
// repeat refreshes — a missing-field count guards the enqueue, and the run
// loop re-checks per field, so fully translated items never reach the model.
// Errors are logged, never returned: a refresh must not fail on this.
func (s *Service) AutoEnqueue(ctx context.Context, itemContentID, language string) {
if !s.cfg.Ready() {
return
}
target, err := subtitles.NormalizeLanguageCode(language)
if err != nil {
return
}
missing, err := s.content.CountMissingFields(ctx, itemContentID, target)
if err != nil {
s.logger.Warn("metadata translation: missing-field count failed",
"content_id", itemContentID, "language", target, "error", err)
return
}
if missing == 0 {
return
}
if _, err := s.Enqueue(ctx, JobRequest{
TargetKind: TargetItem,
ContentID: itemContentID,
TargetLanguage: target,
IncludeChildren: true,
}); err != nil {
s.logger.Warn("metadata translation: auto-enqueue failed",
"content_id", itemContentID, "language", target, "error", err)
}
}
// OnViewMode exposes the viewer-triggered translation mode for status probes.
func (s *Service) OnViewMode() string { return s.cfg.OnViewMode() }
// RequestOnView is the viewer-triggered enqueue path (detail-page button or
// auto-on-view). On top of the normal pipeline it suppresses retries for a
// cooldown window after a failed job for the same target+language: ordinary
// page views must never hammer a broken endpoint. Returns the resulting job
// (which may be the in-flight or recently failed one).
func (s *Service) RequestOnView(ctx context.Context, contentID, targetLanguage string, requestedBy *int) (*Job, error) {
if s.cfg.OnViewMode() == "off" {
return nil, ErrNotConfigured
}
target, err := subtitles.NormalizeLanguageCode(targetLanguage)
if err != nil {
return nil, fmt.Errorf("%w: invalid target language %q", ErrInvalidRequest, targetLanguage)
}
jobs, err := s.repo.ListJobsByContent(ctx, contentID)
if err != nil {
return nil, err
}
for _, job := range jobs {
if job.TargetLanguage != target {
continue
}
if job.Status == jobrunner.StatusFailed && time.Since(job.UpdatedAt) < onViewFailureCooldown {
cooled := job
return &cooled, nil
}
break // most recent job for this language decides; older history is irrelevant
}
return s.Enqueue(ctx, JobRequest{
TargetKind: TargetItem,
ContentID: contentID,
TargetLanguage: target,
IncludeChildren: true,
RequestedBy: requestedBy,
})
}
// GetJob returns a job by ID.
func (s *Service) GetJob(ctx context.Context, id int64) (*Job, error) {
job, err := s.repo.GetJob(ctx, id)
if err != nil {
return nil, err
}
if job == nil {
return nil, ErrJobNotFound
}
return job, nil
}
// ListJobs returns recent jobs for a content ID.
func (s *Service) ListJobs(ctx context.Context, contentID string) ([]Job, error) {
return s.repo.ListJobsByContent(ctx, contentID)
}
// Cancel requests cancellation of a job.
func (s *Service) Cancel(ctx context.Context, id int64) error {
job, err := s.repo.GetJob(ctx, id)
if err != nil {
return err
}
if job == nil {
return ErrJobNotFound
}
if s.runner.Cancel(id) {
return nil
}
if !job.Status.Terminal() {
return s.repo.FailJob(ctx, id, jobrunner.StatusCancelled, "cancelled")
}
return nil
}
func (s *Service) dispatch(job Job) {
s.runner.Dispatch(job.ID, func(ctx context.Context) {
s.run(ctx, &job)
}, func(ctx context.Context) {
_ = s.repo.FailJob(ctx, job.ID, jobrunner.StatusCancelled, "cancelled before start")
})
}
// field is one translatable unit of a job.
type field struct {
kind TargetKind
contentID string
isTagline bool
text string
}
func (f field) segmentID() string {
if f.kind == TargetItem && f.isTagline {
return "item:tagline:" + f.contentID
}
return string(f.kind) + ":overview:" + f.contentID
}
func (s *Service) run(ctx context.Context, job *Job) {
if err := s.repo.UpdateProgress(ctx, job.ID, jobrunner.StatusRunning, 0, "Loading content", 0, 0); err != nil {
s.logger.Warn("failed to mark metadata translation job running", "job", job.ID, "error", err)
}
fields, meta, err := s.collectFields(ctx, job)
if err != nil {
s.finishWithError(ctx, job, err)
return
}
if len(fields) == 0 {
if err := s.repo.CompleteJob(context.WithoutCancel(ctx), job.ID, "Nothing to translate", 0, 0); err != nil {
s.logger.Warn("failed to complete metadata translation job", "job", job.ID, "error", err)
}
return
}
total := len(fields)
if err := s.repo.UpdateProgress(ctx, job.ID, jobrunner.StatusRunning, 0.05, "Translating", 0, total); err != nil {
s.logger.Warn("failed to update metadata translation job", "job", job.ID, "error", err)
}
segments := make([]aitranslate.Segment, total)
byID := make(map[string]field, total)
for i, f := range fields {
segments[i] = aitranslate.Segment{ID: f.segmentID(), Text: f.text}
byID[f.segmentID()] = f
}
srcName := aitranslate.LanguageDisplayName(meta.srcLanguage)
tgtName := aitranslate.LanguageDisplayName(job.TargetLanguage)
// Persist per batch so a cancellation keeps completed fields. A persist
// failure cancels the remaining batches via persistCtx instead of burning
// model calls whose output cannot be stored.
persistCtx, cancelPersist := context.WithCancel(ctx)
defer cancelPersist()
var persistMu sync.Mutex
var persistErr error
done := 0
_, translateErr := aitranslate.Translate(persistCtx, s.chat, aitranslate.Request{
Segments: segments,
SystemPrompt: systemPrompt(srcName, tgtName, meta.title, meta.year),
TargetName: tgtName,
EntryNoun: "descriptions",
BatchSize: metadataBatchSize,
ContextNeighbors: 0,
}, func(batch []aitranslate.Segment, batchDone, batchTotal int) {
// Finished translations should not be thrown away by a late cancel.
storeCtx := context.WithoutCancel(ctx)
for _, seg := range batch {
f, ok := byID[seg.ID]
if !ok {
continue
}
if err := s.persistField(storeCtx, job, f, seg.Text); err != nil {
persistMu.Lock()
if persistErr == nil {
persistErr = err
}
persistMu.Unlock()
cancelPersist()
return
}
done++
}
progress := 0.05 + 0.9*float64(batchDone)/float64(batchTotal)
_ = s.repo.UpdateProgress(storeCtx, job.ID, jobrunner.StatusRunning, progress, "Translating", done, total)
})
persistMu.Lock()
failure := persistErr
persistMu.Unlock()
if failure == nil && translateErr != nil {
failure = translateErr
}
if failure != nil {
s.finishWithError(ctx, job, failure)
return
}
if err := s.repo.CompleteJob(context.WithoutCancel(ctx), job.ID, "", done, total); err != nil {
s.logger.Warn("failed to complete metadata translation job", "job", job.ID, "error", err)
}
}
func (s *Service) persistField(ctx context.Context, job *Job, f field, text string) error {
text = strings.TrimSpace(text)
if text == "" {
return fmt.Errorf("model returned an empty translation for %s", f.segmentID())
}
switch f.kind {
case TargetItem:
if f.isTagline {
return s.locs.UpsertItemAI(ctx, f.contentID, job.TargetLanguage, nil, &text, job.Force)
}
return s.locs.UpsertItemAI(ctx, f.contentID, job.TargetLanguage, &text, nil, job.Force)
case TargetSeason:
return s.locs.UpsertSeasonAI(ctx, f.contentID, job.TargetLanguage, text, job.Force)
case TargetEpisode:
return s.locs.UpsertEpisodeAI(ctx, f.contentID, job.TargetLanguage, text, job.Force)
default:
return fmt.Errorf("unknown field kind %q", f.kind)
}
}
// jobMeta carries the prompt-grounding context for a job.
type jobMeta struct {
title string
year int
srcLanguage string
}
// collectFields expands the job target into translatable fields, dropping any
// field whose localized value is already filled (unless force, which still
// respects manual provenance).
func (s *Service) collectFields(ctx context.Context, job *Job) ([]field, jobMeta, error) {
var meta jobMeta
switch job.TargetKind {
case TargetItem:
item, err := s.content.ItemText(ctx, job.ContentID)
if err != nil {
return nil, meta, fmt.Errorf("load item: %w", err)
}
if item == nil {
return nil, meta, fmt.Errorf("%w: item not found", ErrInvalidRequest)
}
meta = jobMeta{title: item.Title, year: item.Year, srcLanguage: item.DefaultLanguage}
job.SourceLanguage = item.DefaultLanguage
var fields []field
itemFields, err := s.itemFields(ctx, job, item)
if err != nil {
return nil, meta, err
}
fields = append(fields, itemFields...)
if job.IncludeChildren {
childFields, err := s.childFields(ctx, job, job.ContentID)
if err != nil {
return nil, meta, err
}
fields = append(fields, childFields...)
}
return fields, meta, nil
case TargetSeason:
season, seriesID, err := s.content.SeasonByID(ctx, job.ContentID)
if err != nil {
return nil, meta, fmt.Errorf("load season: %w", err)
}
if season == nil {
return nil, meta, fmt.Errorf("%w: season not found", ErrInvalidRequest)
}
meta, err = s.parentMeta(ctx, job, seriesID)
if err != nil {
return nil, meta, err
}
fields, err := s.filterSeasons(ctx, job, []ChildText{*season})
return fields, meta, err
case TargetEpisode:
episode, seriesID, err := s.content.EpisodeByID(ctx, job.ContentID)
if err != nil {
return nil, meta, fmt.Errorf("load episode: %w", err)
}
if episode == nil {
return nil, meta, fmt.Errorf("%w: episode not found", ErrInvalidRequest)
}
meta, err = s.parentMeta(ctx, job, seriesID)
if err != nil {
return nil, meta, err
}
fields, err := s.filterEpisodes(ctx, job, []ChildText{*episode})
return fields, meta, err
default:
return nil, meta, fmt.Errorf("%w: unsupported target kind %q", ErrInvalidRequest, job.TargetKind)
}
}
func (s *Service) parentMeta(ctx context.Context, job *Job, seriesID string) (jobMeta, error) {
var meta jobMeta
item, err := s.content.ItemText(ctx, seriesID)
if err != nil {
return meta, fmt.Errorf("load parent series: %w", err)
}
if item != nil {
meta = jobMeta{title: item.Title, year: item.Year, srcLanguage: item.DefaultLanguage}
job.SourceLanguage = item.DefaultLanguage
}
return meta, nil
}
// translatableField reports whether a field with base text and an existing
// localized value/source should be translated. Empty base text never
// translates; filled localizations are skipped unless force; manual values
// are skipped even with force (the SQL layer guards them too — skipping here
// saves the model call).
func translatableField(baseText, locValue, locSource string, force bool) bool {
if strings.TrimSpace(baseText) == "" {
return false
}
if locSource == "manual" {
return false
}
if locValue != "" && !force {
return false
}
return true
}
func (s *Service) itemFields(ctx context.Context, job *Job, item *ItemText) ([]field, error) {
loc, err := s.locs.ItemLocalization(ctx, item.ContentID, job.TargetLanguage)
if err != nil {
return nil, fmt.Errorf("load item localization: %w", err)
}
var locOverview, locOverviewSrc, locTagline, locTaglineSrc string
if loc != nil {
locOverview, locOverviewSrc = loc.Overview, loc.OverviewSource
locTagline, locTaglineSrc = loc.Tagline, loc.TaglineSource
}
var fields []field
if translatableField(item.Overview, locOverview, locOverviewSrc, job.Force) {
fields = append(fields, field{kind: TargetItem, contentID: item.ContentID, text: item.Overview})
}
if translatableField(item.Tagline, locTagline, locTaglineSrc, job.Force) {
fields = append(fields, field{kind: TargetItem, contentID: item.ContentID, isTagline: true, text: item.Tagline})
}
return fields, nil
}
func (s *Service) childFields(ctx context.Context, job *Job, seriesID string) ([]field, error) {
seasons, err := s.content.SeasonTexts(ctx, seriesID)
if err != nil {
return nil, fmt.Errorf("load seasons: %w", err)
}
fields, err := s.filterSeasons(ctx, job, seasons)
if err != nil {
return nil, err
}
episodes, err := s.content.EpisodeTexts(ctx, seriesID)
if err != nil {
return nil, fmt.Errorf("load episodes: %w", err)
}
episodeFields, err := s.filterEpisodes(ctx, job, episodes)
if err != nil {
return nil, err
}
return append(fields, episodeFields...), nil
}
func (s *Service) filterSeasons(ctx context.Context, job *Job, seasons []ChildText) ([]field, error) {
ids := make([]string, 0, len(seasons))
for _, season := range seasons {
ids = append(ids, season.ContentID)
}
locs, err := s.locs.SeasonLocalizations(ctx, ids, job.TargetLanguage)
if err != nil {
return nil, fmt.Errorf("load season localizations: %w", err)
}
var fields []field
for _, season := range seasons {
var locOverview, locSource string
if loc := locs[season.ContentID]; loc != nil {
locOverview, locSource = loc.Overview, loc.OverviewSource
}
if translatableField(season.Overview, locOverview, locSource, job.Force) {
fields = append(fields, field{kind: TargetSeason, contentID: season.ContentID, text: season.Overview})
}
}
return fields, nil
}
func (s *Service) filterEpisodes(ctx context.Context, job *Job, episodes []ChildText) ([]field, error) {
ids := make([]string, 0, len(episodes))
for _, episode := range episodes {
ids = append(ids, episode.ContentID)
}
locs, err := s.locs.EpisodeLocalizations(ctx, ids, job.TargetLanguage)
if err != nil {
return nil, fmt.Errorf("load episode localizations: %w", err)
}
var fields []field
for _, episode := range episodes {
var locOverview, locSource string
if loc := locs[episode.ContentID]; loc != nil {
locOverview, locSource = loc.Overview, loc.OverviewSource
}
if translatableField(episode.Overview, locOverview, locSource, job.Force) {
fields = append(fields, field{kind: TargetEpisode, contentID: episode.ContentID, text: episode.Overview})
}
}
return fields, nil
}
func (s *Service) finishWithError(ctx context.Context, job *Job, err error) {
status := jobrunner.StatusFailed
msg := llm.Truncate(err.Error(), 500)
if errors.Is(err, context.Canceled) || errors.Is(ctx.Err(), context.Canceled) {
status = jobrunner.StatusCancelled
msg = "cancelled"
}
if dbErr := s.repo.FailJob(context.WithoutCancel(ctx), job.ID, status, msg); dbErr != nil {
s.logger.Warn("failed to record metadata translation job failure", "job", job.ID, "error", dbErr)
}
if status == jobrunner.StatusFailed {
s.logger.Warn("metadata translation job failed", "job", job.ID, "content_id", job.ContentID, "error", err)
}
}
@@ -0,0 +1,544 @@
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)
}
}
+51
View File
@@ -0,0 +1,51 @@
package translation
import (
"context"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
// LocalizationStore reads existing localizations (for skip-if-filled) and
// writes AI translations with provenance enforced by the underlying repos.
type LocalizationStore interface {
ItemLocalization(ctx context.Context, contentID, language string) (*models.MediaItemLocalization, error)
SeasonLocalizations(ctx context.Context, ids []string, language string) (map[string]*models.SeasonLocalization, error)
EpisodeLocalizations(ctx context.Context, ids []string, language string) (map[string]*models.EpisodeLocalization, error)
UpsertItemAI(ctx context.Context, contentID, language string, overview, tagline *string, force bool) error
UpsertSeasonAI(ctx context.Context, contentID, language, overview string, force bool) error
UpsertEpisodeAI(ctx context.Context, contentID, language, overview string, force bool) error
}
// CatalogLocalizationStore adapts the catalog localization repositories to
// LocalizationStore.
type CatalogLocalizationStore struct {
Items *catalog.MediaItemLocalizationRepository
Seasons *catalog.SeasonLocalizationRepository
Episodes *catalog.EpisodeLocalizationRepository
}
func (s *CatalogLocalizationStore) ItemLocalization(ctx context.Context, contentID, language string) (*models.MediaItemLocalization, error) {
return s.Items.Get(ctx, contentID, language)
}
func (s *CatalogLocalizationStore) SeasonLocalizations(ctx context.Context, ids []string, language string) (map[string]*models.SeasonLocalization, error) {
return s.Seasons.GetBySeasonIDs(ctx, ids, language)
}
func (s *CatalogLocalizationStore) EpisodeLocalizations(ctx context.Context, ids []string, language string) (map[string]*models.EpisodeLocalization, error) {
return s.Episodes.GetByEpisodeIDs(ctx, ids, language)
}
func (s *CatalogLocalizationStore) UpsertItemAI(ctx context.Context, contentID, language string, overview, tagline *string, force bool) error {
return s.Items.UpsertAITranslation(ctx, contentID, language, overview, tagline, force)
}
func (s *CatalogLocalizationStore) UpsertSeasonAI(ctx context.Context, contentID, language, overview string, force bool) error {
return s.Seasons.UpsertAIOverview(ctx, contentID, language, overview, force)
}
func (s *CatalogLocalizationStore) UpsertEpisodeAI(ctx context.Context, contentID, language, overview string, force bool) error {
return s.Episodes.UpsertAIOverview(ctx, contentID, language, overview, force)
}
+15
View File
@@ -13,6 +13,7 @@ type MediaFolder struct {
Name string
Enabled bool
MetadataLanguage string // ISO 639-1 code (e.g. "en", "ja")
AutoTranslateMetadata bool // AI-translate descriptions when providers lack this language
ChapterThumbnailsEnabled bool
IntroDetectionEnabled bool
PosterPath string // S3 key for library poster image
@@ -421,6 +422,16 @@ type EpisodeLibrary struct {
FirstSeenAt time.Time
}
// Localization field provenance values. Precedence when writing:
// manual beats provider beats ai — a provider refresh may overwrite an AI
// translation but never a manual edit, and AI never overwrites either
// (except provider, when the admin explicitly forces a re-translation).
const (
LocalizationSourceProvider = "provider"
LocalizationSourceAI = "ai"
LocalizationSourceManual = "manual"
)
type MediaItemLocalization struct {
ContentID string
Language string
@@ -433,6 +444,8 @@ type MediaItemLocalization struct {
BackdropPath string
BackdropThumbhash string
LogoPath string
OverviewSource string // provider | ai | manual
TaglineSource string // provider | ai | manual
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -444,6 +457,7 @@ type SeasonLocalization struct {
Overview string
PosterPath string
PosterThumbhash string
OverviewSource string // provider | ai | manual
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -453,6 +467,7 @@ type EpisodeLocalization struct {
Language string
Title string
Overview string
OverviewSource string // provider | ai | manual
CreatedAt time.Time
UpdatedAt time.Time
}
+180
View File
@@ -0,0 +1,180 @@
package playback
import (
"bytes"
"context"
"encoding/csv"
"encoding/json"
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
)
// AudioChunk is one extracted piece of an audio track. Start is the chunk's
// exact start within the extracted audio (seconds) as reported by ffmpeg's
// segment muxer — the muxer cuts at packet boundaries near, not exactly at,
// the requested length, so assuming index*chunkSeconds would accumulate
// subtitle timing error.
type AudioChunk struct {
Path string
Start float64
}
// ExtractAudioChunks extracts one audio track from a media file into
// fixed-length 16 kHz mono WAV chunks under dir — the input format Whisper
// endpoints want, sized to stay under typical upload limits (a 10-minute
// chunk is ~19 MB). One ffmpeg pass segments the whole track; the returned
// chunks are in chronological order with exact start offsets. The caller owns
// dir and its cleanup.
func ExtractAudioChunks(ctx context.Context, filePath string, audioTrackIndex int, dir, ffmpegPath string, chunkSeconds int) ([]AudioChunk, error) {
if chunkSeconds <= 0 {
chunkSeconds = 600
}
if audioTrackIndex < 0 {
audioTrackIndex = 0
}
listPath := filepath.Join(dir, "segments.csv")
args := []string{
"-i", filePath,
"-vn", "-sn", "-dn",
"-map", fmt.Sprintf("0:a:%d", audioTrackIndex),
"-ac", "1",
"-ar", "16000",
"-c:a", "pcm_s16le",
"-f", "segment",
"-segment_time", strconv.Itoa(chunkSeconds),
"-segment_list", listPath,
"-segment_list_type", "csv",
"-y", filepath.Join(dir, "chunk%05d.wav"),
}
ffmpeg := "ffmpeg"
if ffmpegPath != "" {
ffmpeg = ffmpegPath
}
cmd := exec.CommandContext(ctx, ffmpeg, args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg audio extraction failed: %w (stderr: %s)",
err, truncateStderr(stderr.String()))
}
starts := parseSegmentList(listPath)
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("read audio chunk dir: %w", err)
}
var names []string
for _, e := range entries {
if !e.IsDir() && strings.HasPrefix(e.Name(), "chunk") && strings.HasSuffix(e.Name(), ".wav") {
names = append(names, e.Name())
}
}
sort.Strings(names)
if len(names) == 0 {
return nil, fmt.Errorf("ffmpeg produced no audio chunks for track %d", audioTrackIndex)
}
chunks := make([]AudioChunk, len(names))
for i, name := range names {
start, ok := starts[name]
if !ok {
// Segment list missing/unparsable: fall back to the nominal grid.
start = float64(i * chunkSeconds)
}
chunks[i] = AudioChunk{Path: filepath.Join(dir, name), Start: start}
}
return chunks, nil
}
// parseSegmentList reads ffmpeg's CSV segment list (filename,start,end per
// line) into a filename → start map. Best effort: a missing or malformed list
// yields an empty map and callers fall back to nominal chunk starts.
func parseSegmentList(listPath string) map[string]float64 {
starts := map[string]float64{}
f, err := os.Open(listPath)
if err != nil {
return starts
}
defer f.Close()
rows, err := csv.NewReader(f).ReadAll()
if err != nil {
return starts
}
for _, row := range rows {
if len(row) < 2 {
continue
}
start, err := strconv.ParseFloat(strings.TrimSpace(row[1]), 64)
if err != nil || start < 0 {
continue
}
starts[filepath.Base(strings.TrimSpace(row[0]))] = start
}
return starts
}
// maxPlausibleAudioDelay caps the probed audio/container start delta; beyond
// this it is far more likely a probing artifact than a real stream delay.
const maxPlausibleAudioDelay = 30.0
// ProbeAudioStartOffset returns the audio stream's start time relative to the
// container start (seconds). Whisper timestamps are relative to the first
// audio sample, while the playback timeline starts at the container start —
// in containers with delayed audio (TS remuxes especially) the difference is
// a constant subtitle sync error unless corrected. Best effort: any probe
// failure returns 0.
func ProbeAudioStartOffset(ctx context.Context, filePath string, audioTrackIndex int, ffmpegPath string) float64 {
if audioTrackIndex < 0 {
audioTrackIndex = 0
}
ffprobe := "ffprobe"
if ffmpegPath != "" {
// ffprobe ships next to ffmpeg in every distribution Silo supports.
ffprobe = filepath.Join(filepath.Dir(ffmpegPath), "ffprobe")
}
cmd := exec.CommandContext(ctx, ffprobe,
"-v", "error",
"-print_format", "json",
"-show_entries", "format=start_time",
"-select_streams", fmt.Sprintf("a:%d", audioTrackIndex),
"-show_entries", "stream=start_time",
filePath,
)
out, err := cmd.Output()
if err != nil {
return 0
}
var probe struct {
Format struct {
StartTime string `json:"start_time"`
} `json:"format"`
Streams []struct {
StartTime string `json:"start_time"`
} `json:"streams"`
}
if err := json.Unmarshal(out, &probe); err != nil || len(probe.Streams) == 0 {
return 0
}
formatStart, err1 := strconv.ParseFloat(probe.Format.StartTime, 64)
streamStart, err2 := strconv.ParseFloat(probe.Streams[0].StartTime, 64)
if err1 != nil || err2 != nil {
return 0
}
delta := streamStart - formatStart
if math.IsNaN(delta) || math.Abs(delta) > maxPlausibleAudioDelay {
return 0
}
return delta
}
-190
View File
@@ -1,190 +0,0 @@
package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
)
// Client is a minimal OpenAI-compatible chat-completions client. It follows the
// same retry/backoff conventions as the recommendations embedding client so the
// two behave consistently against OpenAI, Groq, Ollama, llama.cpp servers, etc.
type Client struct {
cfg Config
httpClient *http.Client
}
// NewClient builds a client from the engine config.
func NewClient(cfg Config) *Client {
return &Client{
cfg: cfg,
httpClient: &http.Client{Timeout: 10 * time.Minute},
}
}
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatResponseFormat struct {
Type string `json:"type"`
}
type chatCompletionRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Temperature float32 `json:"temperature"`
ResponseFormat *chatResponseFormat `json:"response_format,omitempty"`
}
type chatCompletionResponse struct {
Choices []struct {
Message chatMessage `json:"message"`
} `json:"choices"`
// Some OpenAI-compatible gateways (e.g. OpenRouter) return a 200 with an
// error object instead of an HTTP error status when an upstream provider
// fails. We surface and retry on it.
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
// chat performs one chat completion and returns the first choice's content.
// When jsonObject is true it requests response_format=json_object; providers
// that ignore the field still work because the prompt itself demands JSON.
func (c *Client) chat(ctx context.Context, messages []chatMessage, jsonObject bool) (string, error) {
reqBody := chatCompletionRequest{
Model: c.cfg.ChatModel,
Messages: messages,
Temperature: 0.2,
}
if jsonObject {
reqBody.ResponseFormat = &chatResponseFormat{Type: "json_object"}
}
body, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal chat request: %w", err)
}
url := strings.TrimRight(c.cfg.BaseURL, "/") + "/v1/chat/completions"
const maxAttempts = 6
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
httpReq, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if reqErr != nil {
return "", fmt.Errorf("create request: %w", reqErr)
}
httpReq.Header.Set("Content-Type", "application/json")
if c.cfg.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
}
resp, doErr := c.httpClient.Do(httpReq)
if doErr != nil {
lastErr = fmt.Errorf("chat request failed: %w", doErr)
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
return "", waitErr
}
continue
}
respBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
// A truncated/failed read could otherwise be misparsed as a valid
// (empty) response; treat it as a retryable transport error.
lastErr = fmt.Errorf("read chat response: %w", readErr)
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
return "", waitErr
}
continue
}
switch {
case resp.StatusCode == http.StatusTooManyRequests:
wait := rateLimitBackoff(resp, attempt)
slog.Warn("rate limited by subtitle AI chat API, waiting", "attempt", attempt+1, "wait", wait)
lastErr = fmt.Errorf("chat API returned 429: %s", truncate(string(respBody), 300))
if waitErr := sleepCtx(ctx, wait); waitErr != nil {
return "", waitErr
}
continue
case resp.StatusCode >= 500:
lastErr = fmt.Errorf("chat API returned %d: %s", resp.StatusCode, truncate(string(respBody), 300))
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
return "", waitErr
}
continue
case resp.StatusCode != http.StatusOK:
// 4xx other than 429: not retryable.
return "", fmt.Errorf("chat API returned %d: %s", resp.StatusCode, truncate(string(respBody), 300))
}
var parsed chatCompletionResponse
if err := json.Unmarshal(respBody, &parsed); err != nil {
lastErr = fmt.Errorf("decode chat response: %w", err)
} else if parsed.Error != nil && parsed.Error.Message != "" {
// 200 with an upstream error object — transient on gateways.
lastErr = fmt.Errorf("chat API error: %s", parsed.Error.Message)
} else if len(parsed.Choices) == 0 || parsed.Choices[0].Message.Content == "" {
lastErr = fmt.Errorf("chat API returned no choices")
slog.Warn("subtitle AI chat returned no choices, retrying",
"attempt", attempt+1, "model", c.cfg.ChatModel, "response_bytes", len(respBody))
} else {
return parsed.Choices[0].Message.Content, nil
}
// Empty-choices / 200-error / decode failure: retry with backoff.
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
return "", waitErr
}
}
if lastErr == nil {
lastErr = fmt.Errorf("chat API: retries exhausted")
}
return "", lastErr
}
// truncate caps a string for inclusion in an error or log line.
func truncate(s string, maxLen int) string {
if len(s) > maxLen {
return s[:maxLen] + "..."
}
return s
}
// sleepCtx waits for d or until ctx is cancelled.
func sleepCtx(ctx context.Context, d time.Duration) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(d):
return nil
}
}
// rateLimitBackoff returns how long to wait after a 429, honoring Retry-After
// and otherwise backing off exponentially (capped at 60s).
func rateLimitBackoff(resp *http.Response, attempt int) time.Duration {
if ra := resp.Header.Get("Retry-After"); ra != "" {
if secs, err := strconv.Atoi(ra); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
}
}
wait := 10 * time.Second * (1 << attempt)
if wait > 60*time.Second {
wait = 60 * time.Second
}
return wait
}
+21 -11
View File
@@ -1,20 +1,30 @@
package ai
// Config holds the runtime configuration for the AI subtitle engine. Values are
// loaded from server_settings (see internal/config) and mirror the existing
// recommendations embedding client: one OpenAI-compatible endpoint that the
// operator can point at OpenAI, Groq, a local Ollama/llama.cpp server, etc.
// Config holds the runtime configuration for the subtitle AI features. The
// shared endpoint connection (base URL, keys) lives in the llm.Client this
// package is wired with; this struct carries the feature toggles plus the
// model names used for job provenance and idempotency.
type Config struct {
Enabled bool
BaseURL string // e.g. "https://api.openai.com" (no trailing /v1)
APIKey string // empty for keyless local servers
// Configured reports that the shared AI endpoint has a base URL; without
// one, no feature is available regardless of toggles.
Configured bool
// TranslateEnabled gates on-demand subtitle translation.
TranslateEnabled bool
// TranscribeEnabled gates Whisper ASR generation (transcribe /
// transcribe_translate jobs).
TranscribeEnabled bool
ChatModel string // chat-completions model used for translation
MaxConcurrentJobs int // semaphore bound so jobs never starve transcodes
ASRModel string // audio-transcription model used for ASR
BatchSize int // cues per translation request
ContextNeighbors int // preceding source cues sent as untranslated context
}
// Ready reports whether the engine is enabled and minimally configured.
func (c Config) Ready() bool {
return c.Enabled && c.BaseURL != "" && c.ChatModel != ""
// TranslateReady reports whether subtitle translation can currently run.
func (c Config) TranslateReady() bool {
return c.Configured && c.TranslateEnabled && c.ChatModel != ""
}
// TranscribeReady reports whether ASR subtitle generation can currently run.
func (c Config) TranscribeReady() bool {
return c.Configured && c.TranscribeEnabled && c.ASRModel != ""
}
+25 -20
View File
@@ -5,38 +5,43 @@ import (
"encoding/hex"
"fmt"
"time"
"github.com/Silo-Server/silo-server/internal/ai/jobrunner"
)
// JobKind identifies what an AI subtitle job does. Only translation ships in
// the first iteration; transcription kinds are reserved for the Whisper ASR
// follow-up so the schema and API do not change again.
// JobKind identifies what an AI subtitle job does.
type JobKind string
const (
// JobKindTranslate translates an existing text subtitle track. SourceIndex
// is the combined player subtitle index of the source track.
JobKindTranslate JobKind = "translate"
// JobKindTranscribe generates a subtitle track from an audio track via
// Whisper ASR. SourceIndex is the 0-based audio track index (-1 = default
// track); TargetLanguage is optional and acts as a language hint.
JobKindTranscribe JobKind = "transcribe"
// JobKindTranscribeTranslate chains transcription with LLM translation to
// TargetLanguage, storing both the transcript and the translated track.
JobKindTranscribeTranslate JobKind = "transcribe_translate"
)
// JobStatus is the lifecycle state of a job.
type JobStatus string
// IsTranscribe reports whether the kind starts from audio.
func (k JobKind) IsTranscribe() bool {
return k == JobKindTranscribe || k == JobKindTranscribeTranslate
}
// JobStatus is the lifecycle state of a job, shared with the other AI job
// services via jobrunner.
type JobStatus = jobrunner.Status
const (
JobStatusPending JobStatus = "pending"
JobStatusRunning JobStatus = "running"
JobStatusCompleted JobStatus = "completed"
JobStatusFailed JobStatus = "failed"
JobStatusCancelled JobStatus = "cancelled"
JobStatusPending = jobrunner.StatusPending
JobStatusRunning = jobrunner.StatusRunning
JobStatusCompleted = jobrunner.StatusCompleted
JobStatusFailed = jobrunner.StatusFailed
JobStatusCancelled = jobrunner.StatusCancelled
)
// Terminal reports whether a status is final.
func (s JobStatus) Terminal() bool {
switch s {
case JobStatusCompleted, JobStatusFailed, JobStatusCancelled:
return true
default:
return false
}
}
// Job is a persisted AI subtitle job. It is serialized to the API as-is.
type Job struct {
ID int64 `json:"id"`
+281 -160
View File
@@ -8,25 +8,19 @@ import (
"os"
"sort"
"strings"
"sync"
"time"
"github.com/Silo-Server/silo-server/internal/ai/jobrunner"
"github.com/Silo-Server/silo-server/internal/ai/llm"
aitranslate "github.com/Silo-Server/silo-server/internal/ai/translate"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/subtitles"
)
const providerTranslated = "translated"
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
providerTranslated = "translated"
providerTranscribed = "transcribed"
)
var (
@@ -61,129 +55,116 @@ type SubtitleLister interface {
ListDownloadedSubtitles(ctx context.Context, mediaFileID int) ([]subtitles.DownloadedSubtitle, error)
}
// Service owns the AI subtitle job lifecycle: enqueue, bounded concurrent
// execution, progress/heartbeat, cancellation, and restart recovery.
// Service owns the AI subtitle job semantics: enqueue validation, source
// resolution, translation, and result storage. Lifecycle mechanics (bounded
// dispatch, heartbeat, stale-job reaping, cancellation) are delegated to the
// shared jobrunner so they stay identical across Silo's AI job services.
type Service 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
cfg Config
repo JobRepository
translator Translator
store SubtitleStore
lister SubtitleLister
files MediaFileResolver
notifier Notifier // optional
ffmpegPath string
logger *slog.Logger
sem chan struct{}
mu sync.Mutex
cancels map[int64]context.CancelFunc
wg sync.WaitGroup
cfg Config
repo JobRepository
translator Translator
transcriber Transcriber // optional; nil disables ASR kinds
store SubtitleStore
lister SubtitleLister
files MediaFileResolver
notifier Notifier // optional
ffmpegPath string
logger *slog.Logger
runner *jobrunner.Runner
}
// NewService wires a translation service. notifier may be nil. appCtx is the
// application lifecycle context; jobs and the reaper derive from it so they stop
// on shutdown. A nil appCtx falls back to context.Background().
// on shutdown. A nil appCtx falls back to context.Background(). sem is the
// dispatch semaphore, normally shared with the other AI job services so the
// configured endpoint sees one global concurrency bound; nil gets a private
// default-size semaphore.
func NewService(
appCtx context.Context,
cfg Config,
repo JobRepository,
translator Translator,
transcriber Transcriber,
store SubtitleStore,
lister SubtitleLister,
files MediaFileResolver,
notifier Notifier,
ffmpegPath string,
logger *slog.Logger,
sem chan struct{},
) *Service {
maxConcurrent := cfg.MaxConcurrentJobs
if maxConcurrent <= 0 {
maxConcurrent = 2
}
if logger == nil {
logger = slog.Default()
}
if appCtx == nil {
appCtx = context.Background()
}
return &Service{
baseCtx: appCtx,
cfg: cfg,
repo: repo,
translator: translator,
store: store,
lister: lister,
files: files,
notifier: notifier,
ffmpegPath: ffmpegPath,
logger: logger,
sem: make(chan struct{}, maxConcurrent),
cancels: make(map[int64]context.CancelFunc),
cfg: cfg,
repo: repo,
translator: translator,
transcriber: transcriber,
store: store,
lister: lister,
files: files,
notifier: notifier,
ffmpegPath: ffmpegPath,
logger: logger,
runner: jobrunner.New(appCtx, sem, repo, "subtitle ai", logger),
}
}
// Enabled reports whether translation can currently run.
func (s *Service) Enabled() bool { return s.cfg.Ready() }
func (s *Service) Enabled() bool { return s.cfg.TranslateReady() }
// TranscribeEnabled reports whether ASR subtitle generation can currently run.
func (s *Service) TranscribeEnabled() bool {
return s.cfg.TranscribeReady() && s.transcriber != nil
}
// 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 passed to NewService,
// so they stop on shutdown.
// reaper that keeps doing so. Call once at startup.
func (s *Service) Recover() {
s.reapStaleJobs()
go s.reaperLoop()
}
// reaperLoop periodically reaps orphaned jobs until the application context is
// cancelled (server shutdown).
func (s *Service) reaperLoop() {
ticker := time.NewTicker(reaperInterval)
defer ticker.Stop()
for {
select {
case <-s.baseCtx.Done():
return
case <-ticker.C:
s.reapStaleJobs()
}
}
}
// reapStaleJobs fails any pending/running job whose heartbeat has not advanced
// within staleJobThreshold.
func (s *Service) reapStaleJobs() {
before := time.Now().Add(-staleJobThreshold)
n, err := s.repo.ResetStaleJobs(context.WithoutCancel(s.baseCtx), before, "interrupted by server restart")
if err != nil {
s.logger.Warn("failed to reset stale subtitle ai jobs", "error", err)
return
}
if n > 0 {
s.logger.Info("reset stale subtitle ai jobs", "count", n)
}
s.runner.Recover()
}
// Enqueue validates and queues a job, returning immediately. If an identical
// job is already pending or running, that job is returned instead of a new one.
func (s *Service) Enqueue(ctx context.Context, req JobRequest) (*Job, error) {
if !s.cfg.Ready() {
return nil, ErrEngineNotConfigured
}
if req.Kind == "" {
req.Kind = JobKindTranslate
}
target, err := subtitles.NormalizeLanguageCode(req.TargetLanguage)
if err != nil {
return nil, fmt.Errorf("%w: invalid target language %q", ErrInvalidRequest, req.TargetLanguage)
jobModel := s.cfg.ChatModel
switch req.Kind {
case JobKindTranslate:
if !s.cfg.TranslateReady() {
return nil, ErrEngineNotConfigured
}
case JobKindTranscribe:
if !s.TranscribeEnabled() {
return nil, ErrEngineNotConfigured
}
jobModel = s.cfg.ASRModel
case JobKindTranscribeTranslate:
if !s.TranscribeEnabled() || !s.cfg.TranslateReady() {
return nil, ErrEngineNotConfigured
}
jobModel = s.cfg.ASRModel + "+" + s.cfg.ChatModel
default:
return nil, fmt.Errorf("%w: unsupported job kind %q", ErrInvalidRequest, req.Kind)
}
req.TargetLanguage = target
key := idempotencyKey(req.MediaFileID, req.Kind, req.SourceIndex, req.TargetLanguage, s.cfg.ChatModel)
// A plain transcribe has no target language (the track comes out in the
// spoken language); when provided it acts as a language hint. Every other
// kind requires a valid target.
if req.Kind == JobKindTranscribe && strings.TrimSpace(req.TargetLanguage) == "" {
req.TargetLanguage = ""
} else {
target, err := subtitles.NormalizeLanguageCode(req.TargetLanguage)
if err != nil {
return nil, fmt.Errorf("%w: invalid target language %q", ErrInvalidRequest, req.TargetLanguage)
}
req.TargetLanguage = target
}
key := idempotencyKey(req.MediaFileID, req.Kind, req.SourceIndex, req.TargetLanguage, jobModel)
if existing, err := s.repo.GetActiveJobByIdempotencyKey(ctx, key); err != nil {
return nil, err
} else if existing != nil {
@@ -197,7 +178,7 @@ func (s *Service) Enqueue(ctx context.Context, req JobRequest) (*Job, error) {
SourceLanguage: req.SourceLanguage,
TargetLanguage: req.TargetLanguage,
Engine: "openai",
Model: s.cfg.ChatModel,
Model: jobModel,
Status: JobStatusPending,
ProgressMessage: "Queued",
IdempotencyKey: key,
@@ -244,12 +225,7 @@ func (s *Service) Cancel(ctx context.Context, id int64) error {
return ErrJobNotFound
}
s.mu.Lock()
cancel := s.cancels[id]
s.mu.Unlock()
if cancel != nil {
cancel()
if s.runner.Cancel(id) {
return nil
}
// No in-flight goroutine (e.g. another node, or never started): best-effort
@@ -262,63 +238,18 @@ func (s *Service) Cancel(ctx context.Context, id int64) error {
// dispatch launches a bounded background goroutine to run the job.
func (s *Service) dispatch(job Job) {
// Derive from the application context so a server shutdown cancels in-flight
// translations (the per-job cancel still allows user-initiated cancellation).
runCtx, cancel := context.WithCancel(s.baseCtx)
s.mu.Lock()
s.cancels[job.ID] = cancel
s.mu.Unlock()
s.wg.Add(1)
go func() {
defer s.wg.Done()
defer func() {
s.mu.Lock()
delete(s.cancels, job.ID)
s.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 s.heartbeatLoop(runCtx, job.ID, stopHeartbeat)
// Bound concurrency so translation never starves transcodes.
select {
case s.sem <- struct{}{}:
case <-runCtx.Done():
_ = s.repo.FailJob(context.Background(), job.ID, JobStatusCancelled, "cancelled before start")
return
}
defer func() { <-s.sem }()
s.run(runCtx, &job)
}()
}
// 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 (s *Service) 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:
_ = s.repo.Heartbeat(context.WithoutCancel(ctx), jobID)
}
}
s.runner.Dispatch(job.ID, func(ctx context.Context) {
s.run(ctx, &job)
}, func(ctx context.Context) {
_ = s.repo.FailJob(ctx, job.ID, JobStatusCancelled, "cancelled before start")
})
}
func (s *Service) run(ctx context.Context, job *Job) {
if job.Kind.IsTranscribe() {
s.runTranscribe(ctx, job)
return
}
if err := s.repo.UpdateProgress(ctx, job.ID, JobStatusRunning, 0, "Loading subtitle"); err != nil {
s.logger.Warn("failed to mark subtitle ai job running", "job", job.ID, "error", err)
}
@@ -397,9 +328,199 @@ func (s *Service) run(ctx context.Context, job *Job) {
}
}
// runTranscribe executes transcribe / transcribe_translate jobs: extract the
// audio track, transcribe it chunk by chunk (playhead-first), store the
// transcript as an ordinary downloaded subtitle, and for the chained kind run
// the regular translator over the transcript and store the translated track
// too. The transcript doubles as a cache: a later translation to another
// language can use it as a plain text source without re-running ASR.
func (s *Service) runTranscribe(ctx context.Context, job *Job) {
if err := s.repo.UpdateProgress(ctx, job.ID, JobStatusRunning, 0, "Preparing audio"); err != nil {
s.logger.Warn("failed to mark subtitle ai job running", "job", job.ID, "error", err)
}
file, err := s.files.GetByID(ctx, job.MediaFileID)
if err != nil {
s.finishWithError(ctx, job, fmt.Errorf("load media file: %w", err))
return
}
if file == nil {
s.finishWithError(ctx, job, fmt.Errorf("media file not found"))
return
}
if len(file.AudioTracks) == 0 {
s.finishWithError(ctx, job, fmt.Errorf("%w: file has no audio tracks", ErrSourceUnsupported))
return
}
audioIdx := job.SourceIndex
if audioIdx < 0 {
audioIdx = defaultAudioTrackIndex(file.AudioTracks)
}
if audioIdx >= len(file.AudioTracks) {
s.finishWithError(ctx, job, fmt.Errorf("%w: audio track index out of range", ErrInvalidRequest))
return
}
// Language hint: an explicit source language wins; otherwise the track's
// tagged language, when it normalizes to an ISO code.
hint := job.SourceLanguage
if hint == "" {
if code, err := subtitles.NormalizeLanguageCode(file.AudioTracks[audioIdx].Language); err == nil {
hint = code
}
}
if job.Kind == JobKindTranscribe && hint == "" && job.TargetLanguage != "" {
hint = job.TargetLanguage
}
streaming := job.SessionID != "" && s.notifier != nil
trackKey := liveTrackKey(job.ID)
if streaming {
liveLang, liveLabel := job.TargetLanguage, transcribedReleaseName(hint)
if job.Kind == JobKindTranscribeTranslate {
liveLabel = translatedReleaseName(hint, job.TargetLanguage)
} else {
liveLang = hint
}
// Cue total is unknown before transcription; 0 means indeterminate.
s.notifier.TranslationStarted(ctx, job.SessionID, job.MediaFileID, job.ID, trackKey, liveLang, liveLabel, 0)
}
// Only a plain transcribe streams transcript cues — the chained kind
// streams the translated cues from its translation stage instead.
streamTranscript := streaming && job.Kind == JobKindTranscribe
cues, detected, err := s.transcriber.Transcribe(ctx, TranscribeJobRequest{
FilePath: file.FilePath,
AudioTrackIndex: audioIdx,
LanguageHint: hint,
StartPosition: job.StartPosition,
}, func(chunk []SubtitleCue, done, total int) {
// Transcription occupies the 5%..70% progress band (chunk granularity).
_ = s.repo.UpdateProgress(ctx, job.ID, JobStatusRunning, 0.05+0.65*float64(done)/float64(total), "Transcribing")
if streamTranscript {
s.notifier.TranslationCues(ctx, job.SessionID, job.MediaFileID, job.ID, trackKey,
toStreamCues(chunk), done, total)
}
})
if err != nil {
s.finishWithError(ctx, job, err)
return
}
language := hint
if language == "" {
language = detected
}
if job.SourceLanguage == "" {
job.SourceLanguage = language
}
// A finished transcription should not be thrown away by a last-moment cancel.
storeCtx := context.WithoutCancel(ctx)
transcriptCues := make([]SubtitleCue, len(cues))
copy(transcriptCues, cues)
sortCuesByStart(transcriptCues)
transcriptLabel := transcribedReleaseName(language)
transcript, err := s.store.StoreSubtitle(storeCtx, subtitles.StoreSubtitleRequest{
MediaFileID: job.MediaFileID,
UserID: job.RequestedBy,
Provider: providerTranscribed,
Language: language,
Format: subtitles.FormatSRT,
ReleaseName: transcriptLabel,
Data: SerializeSRT(transcriptCues),
})
if err != nil {
s.finishWithError(ctx, job, fmt.Errorf("store transcribed subtitle: %w", err))
return
}
if s.notifier != nil {
s.notifier.SubtitleReady(storeCtx, job.MediaFileID, transcript.ID, language, transcriptLabel)
}
if job.Kind == JobKindTranscribe {
if err := s.repo.CompleteJob(storeCtx, job.ID, transcript.ID); err != nil {
s.logger.Warn("failed to mark subtitle ai job complete", "job", job.ID, "error", err)
}
if streaming {
s.notifier.TranslationCompleted(storeCtx, job.SessionID, job.MediaFileID, job.ID, trackKey,
transcript.ID, language, transcriptLabel)
}
return
}
// transcribe_translate: run the transcript through the regular translator.
// Cues already arrive playhead-first from chunk ordering, so the viewer's
// region translates (and streams) first here too.
translated, err := s.translator.Translate(ctx, TranslateRequest{
Cues: cues,
SourceLanguage: language,
TargetLanguage: job.TargetLanguage,
}, func(batch []SubtitleCue, done, total int) {
// Translation occupies the 70%..95% band.
_ = s.repo.UpdateProgress(ctx, job.ID, JobStatusRunning, 0.7+0.25*float64(done)/float64(total), "Translating")
if streaming {
s.notifier.TranslationCues(ctx, job.SessionID, job.MediaFileID, job.ID, trackKey,
toStreamCues(batch), done, total)
}
})
if err != nil {
s.finishWithError(ctx, job, err)
return
}
storeCtx = context.WithoutCancel(ctx)
sortCuesByStart(translated)
translatedLabel := translatedReleaseName(language, job.TargetLanguage)
sub, err := s.store.StoreSubtitle(storeCtx, subtitles.StoreSubtitleRequest{
MediaFileID: job.MediaFileID,
UserID: job.RequestedBy,
Provider: providerTranslated,
Language: job.TargetLanguage,
Format: subtitles.FormatSRT,
ReleaseName: translatedLabel,
Data: SerializeSRT(translated),
})
if err != nil {
s.finishWithError(ctx, job, fmt.Errorf("store translated subtitle: %w", err))
return
}
if err := s.repo.CompleteJob(storeCtx, job.ID, sub.ID); err != nil {
s.logger.Warn("failed to mark subtitle ai job complete", "job", job.ID, "error", err)
}
if s.notifier != nil {
if streaming {
s.notifier.TranslationCompleted(storeCtx, job.SessionID, job.MediaFileID, job.ID, trackKey,
sub.ID, job.TargetLanguage, translatedLabel)
}
s.notifier.SubtitleReady(storeCtx, job.MediaFileID, sub.ID, job.TargetLanguage, translatedLabel)
}
}
// defaultAudioTrackIndex returns the index of the default-flagged audio
// track, falling back to the first track.
func defaultAudioTrackIndex(tracks []models.AudioTrack) int {
for i, t := range tracks {
if t.Default {
return i
}
}
return 0
}
func transcribedReleaseName(lang string) string {
name := aitranslate.LanguageDisplayName(lang)
if name == "" {
name = "Audio"
}
return fmt.Sprintf("%s (AI transcribed)", name)
}
func (s *Service) finishWithError(ctx context.Context, job *Job, err error) {
status := JobStatusFailed
msg := truncate(err.Error(), 500)
msg := llm.Truncate(err.Error(), 500)
// Only a genuine cancellation (user cancel, or shutdown via cancel) becomes
// "cancelled". A deadline/timeout (context.DeadlineExceeded) stays "failed".
if errors.Is(err, context.Canceled) || errors.Is(ctx.Err(), context.Canceled) {
@@ -505,11 +626,11 @@ func isParsableTextFormat(format string) bool {
}
func translatedReleaseName(sourceLang, targetLang string) string {
src := languageDisplayName(sourceLang)
src := aitranslate.LanguageDisplayName(sourceLang)
if src == "" {
src = "Original"
}
tgt := languageDisplayName(targetLang)
tgt := aitranslate.LanguageDisplayName(targetLang)
if tgt == "" {
tgt = targetLang
}
+4 -2
View File
@@ -5,6 +5,8 @@ import (
"sync"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/ai/jobrunner"
)
// recordingRepo is a JobRepository that records ResetStaleJobs calls and no-ops
@@ -49,7 +51,7 @@ func TestRecoverReapsStaleJobsImmediately(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // stops the reaper goroutine started by Recover
svc := NewService(ctx, Config{}, repo, nil, nil, nil, nil, nil, "", nil)
svc := NewService(ctx, Config{}, repo, nil, nil, nil, nil, nil, nil, "", nil, nil)
approxNow := time.Now()
svc.Recover()
@@ -58,7 +60,7 @@ func TestRecoverReapsStaleJobsImmediately(t *testing.T) {
if resets < 1 {
t.Fatalf("Recover did not reap immediately: resets=%d", resets)
}
want := approxNow.Add(-staleJobThreshold)
want := approxNow.Add(-jobrunner.StaleJobThreshold)
if diff := before.Sub(want); diff > 2*time.Second || diff < -2*time.Second {
t.Errorf("stale cutoff = %v, want ~%v (now-staleJobThreshold)", before, want)
}
+327
View File
@@ -0,0 +1,327 @@
package ai
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"unicode/utf8"
"github.com/Silo-Server/silo-server/internal/ai/llm"
aitranslate "github.com/Silo-Server/silo-server/internal/ai/translate"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/subtitles"
)
const (
// Default/maximum audio chunk length sent per transcription request.
// 10 minutes of 16 kHz mono WAV is ~19 MB — under typical 25 MB API
// limits. Shorter chunks bound Whisper's within-chunk timestamp drift at
// the cost of more requests and more boundary word-clips; the operator
// tunes this via subtitle_ai.asr_chunk_seconds.
defaultASRChunkSeconds = 600
minASRChunkSeconds = 60
// Per-chunk request timeout: 3× realtime accommodates local Whisper
// servers on modest hardware.
asrChunkTimeoutFactor = 3
// Cue text wrapping: standard subtitle conventions.
cueMaxLineLength = 42
cueMaxLines = 2
// Cue timing conventions. When word timings are available a cue closes
// at a speech pause, at text capacity, at the maximum duration, or after
// a sentence ends; without them a segment becomes one duration-capped
// cue. Sub-second cues are stretched to the minimum so short
// interjections ("What?") stay readable.
maxCueSeconds = 7.0
minCueSeconds = 1.0
cueSplitPauseSeconds = 1.0
)
// TranscribeJobRequest is the input to a Transcriber.
type TranscribeJobRequest struct {
FilePath string
AudioTrackIndex int // resolved 0-based audio stream index
LanguageHint string // ISO 639-1; "" lets the model detect
StartPosition float64 // seconds; chunks are processed playhead-first
}
// Transcriber converts an audio track into subtitle cues. The built-in
// implementation is WhisperTranscriber; the interface is the seam for tests
// and future engines. onChunk, when non-nil, receives each chunk's cues as
// they land (chronological within a chunk, playhead-first across chunks) so
// callers can report progress and stream cues live.
type Transcriber interface {
Transcribe(ctx context.Context, req TranscribeJobRequest,
onChunk func(cues []SubtitleCue, done, total int)) ([]SubtitleCue, string, error)
}
// audioTranscriber is the slice of llm.Client the transcriber needs.
type audioTranscriber interface {
Transcribe(ctx context.Context, req llm.TranscribeRequest) (*llm.Transcription, error)
}
// WhisperTranscriber generates subtitles from audio via an OpenAI-compatible
// transcription endpoint: extract the track to 16 kHz mono WAV chunks, send
// each chunk for verbose_json transcription, offset the segment timestamps by
// the chunk start, and build wrapped cues. Chunk boundaries are fixed-length;
// a word straddling a boundary can be clipped — accepted v1 limitation, noted
// for a silence-aligned follow-up.
type WhisperTranscriber struct {
client audioTranscriber
ffmpegPath string
chunkSeconds int
// extract and probeOffset are playback helpers, injectable for tests.
extract func(ctx context.Context, filePath string, audioTrackIndex int, dir, ffmpegPath string, chunkSeconds int) ([]playback.AudioChunk, error)
probeOffset func(ctx context.Context, filePath string, audioTrackIndex int, ffmpegPath string) float64
}
// NewWhisperTranscriber builds a transcriber backed by the shared AI client.
// chunkSeconds outside [minASRChunkSeconds, defaultASRChunkSeconds] falls
// back to the default (longer chunks would exceed upload limits).
func NewWhisperTranscriber(client *llm.Client, ffmpegPath string, chunkSeconds int) *WhisperTranscriber {
if chunkSeconds < minASRChunkSeconds || chunkSeconds > defaultASRChunkSeconds {
chunkSeconds = defaultASRChunkSeconds
}
return &WhisperTranscriber{
client: client,
ffmpegPath: ffmpegPath,
chunkSeconds: chunkSeconds,
extract: playback.ExtractAudioChunks,
probeOffset: playback.ProbeAudioStartOffset,
}
}
// Transcribe implements Transcriber. The returned cues are NOT sorted (they
// arrive playhead-first across chunks); the detected language is the
// endpoint's report for the first processed chunk, normalized to an ISO code
// where possible.
func (t *WhisperTranscriber) Transcribe(ctx context.Context, req TranscribeJobRequest,
onChunk func(cues []SubtitleCue, done, total int)) ([]SubtitleCue, string, error) {
dir, err := os.MkdirTemp("", "silo-asr-*")
if err != nil {
return nil, "", fmt.Errorf("create ASR temp dir: %w", err)
}
defer os.RemoveAll(dir)
chunks, err := t.extract(ctx, req.FilePath, req.AudioTrackIndex, dir, t.ffmpegPath, t.chunkSeconds)
if err != nil {
return nil, "", err
}
// Audio streams can start after the container's timeline origin (TS
// remuxes especially); Whisper times are relative to the first audio
// sample, so the delta is a constant sync error unless added back.
startOffset := t.probeOffset(ctx, req.FilePath, req.AudioTrackIndex, t.ffmpegPath)
order := chunkOrderForPosition(chunks, req.StartPosition)
timeout := time.Duration(t.chunkSeconds*asrChunkTimeoutFactor) * time.Second
var all []SubtitleCue
detected := ""
for done, idx := range order {
if err := ctx.Err(); err != nil {
return nil, "", err
}
data, err := os.ReadFile(chunks[idx].Path)
if err != nil {
return nil, "", fmt.Errorf("read audio chunk: %w", err)
}
tr, err := t.client.Transcribe(ctx, llm.TranscribeRequest{
Filename: filepath.Base(chunks[idx].Path),
Audio: data,
Language: req.LanguageHint,
Timeout: timeout,
})
if err != nil {
return nil, "", fmt.Errorf("transcribe chunk %d/%d: %w", idx+1, len(chunks), err)
}
// Each chunk is read exactly once; deleting it as we go caps disk usage
// at one extraction rather than extraction + retranscription leftovers.
_ = os.Remove(chunks[idx].Path)
if detected == "" {
detected = normalizeDetectedLanguage(tr.Language)
}
cues := cuesFromSegments(tr.Segments, chunks[idx].Start+startOffset)
all = append(all, cues...)
if onChunk != nil {
onChunk(cues, done+1, len(order))
}
}
if len(all) == 0 {
return nil, detected, fmt.Errorf("no speech recognized in the audio track")
}
return all, detected, nil
}
// chunkOrderForPosition orders chunk indexes so the chunk containing
// startSeconds is processed first, then forward, then wrapping to the start —
// the viewer's current region fills first, mirroring translation's
// playhead-first cue order.
func chunkOrderForPosition(chunks []playback.AudioChunk, startSeconds float64) []int {
n := len(chunks)
pivot := 0
if startSeconds > 0 {
for i := n - 1; i >= 0; i-- {
if chunks[i].Start <= startSeconds {
pivot = i
break
}
}
}
order := make([]int, 0, n)
for i := pivot; i < n; i++ {
order = append(order, i)
}
for i := 0; i < pivot; i++ {
order = append(order, i)
}
return order
}
// cuesFromSegments converts transcription segments (timestamps relative to
// their chunk) to absolute-time cues, dropping speech-free segments and
// wrapping text to subtitle conventions. Segments with word timings are
// re-split into readable cues that end when speech stops; segments without
// them become single cues capped at the maximum duration, because Whisper
// segment end times otherwise stretch across silence to the next segment.
func cuesFromSegments(segments []llm.TranscriptionSegment, offsetSeconds float64) []SubtitleCue {
var out []SubtitleCue
for _, seg := range segments {
text := strings.TrimSpace(seg.Text)
if text == "" {
continue
}
if cues := cuesFromWords(seg.Words, offsetSeconds); len(cues) > 0 {
out = append(out, cues...)
continue
}
start := offsetSeconds + seg.Start
end := offsetSeconds + seg.End
if maxEnd := start + maxCueSeconds; end > maxEnd {
end = maxEnd
}
out = append(out, newCue(start, end, text))
}
enforceMinCueDurations(out)
return out
}
// cuesFromWords groups word timings into cues: a cue closes at a speech
// pause, when the text would exceed the wrap capacity, when it would exceed
// the maximum duration, or after a sentence ends once a full line is
// accumulated. Cue end times come from the last word, so cues disappear when
// speech stops instead of lingering through silence.
func cuesFromWords(words []llm.TranscriptionWord, offsetSeconds float64) []SubtitleCue {
maxRunes := cueMaxLineLength * cueMaxLines
var out []SubtitleCue
var texts []string
var start, end float64
runes := 0
flush := func() {
if len(texts) > 0 {
out = append(out, newCue(offsetSeconds+start, offsetSeconds+end, strings.Join(texts, " ")))
}
texts, runes = nil, 0
}
for _, w := range words {
text := strings.TrimSpace(w.Text)
if text == "" {
continue
}
n := utf8.RuneCountInString(text)
if len(texts) > 0 &&
(w.Start-end >= cueSplitPauseSeconds || runes+1+n > maxRunes || w.End-start > maxCueSeconds) {
flush()
}
if len(texts) == 0 {
start = w.Start
}
texts = append(texts, text)
runes += n + 1
end = w.End
if runes >= cueMaxLineLength && endsSentence(text) {
flush()
}
}
flush()
return out
}
// endsSentence reports whether a word closes a sentence, tolerating a
// trailing quote or bracket after the punctuation.
func endsSentence(word string) bool {
word = strings.TrimRight(word, `"')]”’`)
return strings.HasSuffix(word, ".") || strings.HasSuffix(word, "?") ||
strings.HasSuffix(word, "!") || strings.HasSuffix(word, "…")
}
// newCue builds a wrapped cue, guarding degenerate timestamps with a minimal
// visible duration.
func newCue(startSec, endSec float64, text string) SubtitleCue {
if endSec <= startSec {
endSec = startSec + 0.5
}
return SubtitleCue{
Start: time.Duration(startSec * float64(time.Second)),
End: time.Duration(endSec * float64(time.Second)),
Lines: wrapCueText(text, cueMaxLineLength, cueMaxLines),
}
}
// enforceMinCueDurations stretches sub-minimum cues (word-accurate timing can
// produce a 0.2s "What?") up to the readable minimum, without overlapping the
// next cue. Cues are chronological within a chunk.
func enforceMinCueDurations(cues []SubtitleCue) {
minDur := time.Duration(minCueSeconds * float64(time.Second))
for i := range cues {
minEnd := cues[i].Start + minDur
if i+1 < len(cues) && minEnd > cues[i+1].Start {
minEnd = cues[i+1].Start
}
if cues[i].End < minEnd {
cues[i].End = minEnd
}
}
}
// wrapCueText greedily wraps text into at most maxLines lines of roughly
// maxLen characters (counted in runes, so multi-byte scripts like Arabic or
// Cyrillic wrap at the same visual width as Latin). The last line absorbs any
// overflow — text is never dropped.
func wrapCueText(text string, maxLen, maxLines int) []string {
words := strings.Fields(text)
if len(words) == 0 {
return nil
}
lines := []string{words[0]}
for _, w := range words[1:] {
last := len(lines) - 1
switch {
case utf8.RuneCountInString(lines[last])+1+utf8.RuneCountInString(w) <= maxLen:
lines[last] += " " + w
case len(lines) < maxLines:
lines = append(lines, w)
default:
lines[last] += " " + w
}
}
return lines
}
// normalizeDetectedLanguage maps a Whisper-reported language to an ISO 639-1
// code: endpoints variously report codes ("en") or English names ("english").
// Returns "" when it cannot be normalized.
func normalizeDetectedLanguage(reported string) string {
reported = strings.TrimSpace(reported)
if reported == "" {
return ""
}
if code, err := subtitles.NormalizeLanguageCode(reported); err == nil {
return code
}
return aitranslate.LanguageCodeFromName(reported)
}
+374
View File
@@ -0,0 +1,374 @@
package ai
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/ai/llm"
"github.com/Silo-Server/silo-server/internal/playback"
)
// fakeASRClient transcribes by returning canned segments per chunk filename.
type fakeASRClient struct {
perChunk map[string][]llm.TranscriptionSegment
language string
requests []llm.TranscribeRequest
err error
}
func (f *fakeASRClient) Transcribe(_ context.Context, req llm.TranscribeRequest) (*llm.Transcription, error) {
f.requests = append(f.requests, llm.TranscribeRequest{
Filename: req.Filename, Language: req.Language, Timeout: req.Timeout,
})
if f.err != nil {
return nil, f.err
}
return &llm.Transcription{Language: f.language, Segments: f.perChunk[req.Filename]}, nil
}
// stubExtract writes fake chunk files into dir (one per start offset) and
// records dir for cleanup assertions.
func stubExtract(starts []float64, recordDir *string) func(context.Context, string, int, string, string, int) ([]playback.AudioChunk, error) {
return func(_ context.Context, _ string, _ int, dir, _ string, _ int) ([]playback.AudioChunk, error) {
*recordDir = dir
var chunks []playback.AudioChunk
for i, start := range starts {
p := filepath.Join(dir, fmt.Sprintf("chunk%05d.wav", i))
if err := os.WriteFile(p, []byte("RIFF"), 0o644); err != nil {
return nil, err
}
chunks = append(chunks, playback.AudioChunk{Path: p, Start: start})
}
return chunks, nil
}
}
func evenStarts(n int) []float64 {
starts := make([]float64, n)
for i := range starts {
starts[i] = float64(i * 600)
}
return starts
}
func newTestTranscriber(client *fakeASRClient, chunks int, recordDir *string) *WhisperTranscriber {
return &WhisperTranscriber{
client: client,
chunkSeconds: 600,
extract: stubExtract(evenStarts(chunks), recordDir),
probeOffset: func(context.Context, string, int, string) float64 { return 0 },
}
}
func TestTranscribeOffsetsTimestampsByChunkStart(t *testing.T) {
var dir string
client := &fakeASRClient{
language: "english",
perChunk: map[string][]llm.TranscriptionSegment{
"chunk00000.wav": {{Start: 1, End: 3, Text: " hello"}},
"chunk00001.wav": {{Start: 2, End: 4, Text: " world"}},
},
}
tr := newTestTranscriber(client, 2, &dir)
cues, lang, err := tr.Transcribe(context.Background(), TranscribeJobRequest{FilePath: "/x.mkv"}, nil)
if err != nil {
t.Fatalf("Transcribe: %v", err)
}
if lang != "en" {
t.Errorf("detected language = %q, want en (normalized from %q)", lang, client.language)
}
if len(cues) != 2 {
t.Fatalf("cues = %d, want 2", len(cues))
}
// Chunk 1 segment offset by 600s.
if cues[1].Start != 602*time.Second || cues[1].End != 604*time.Second {
t.Errorf("offset cue = %v–%v, want 602s–604s", cues[1].Start, cues[1].End)
}
if strings.Join(cues[1].Lines, " ") != "world" {
t.Errorf("cue text = %q", cues[1].Lines)
}
if dir == "" {
t.Fatal("extract dir not recorded")
}
if _, statErr := os.Stat(dir); !os.IsNotExist(statErr) {
t.Errorf("temp dir %s not cleaned up", dir)
}
}
func TestTranscribeProcessesChunksPlayheadFirst(t *testing.T) {
var dir string
client := &fakeASRClient{
language: "ja",
perChunk: map[string][]llm.TranscriptionSegment{
"chunk00000.wav": {{Start: 0, End: 1, Text: "a"}},
"chunk00001.wav": {{Start: 0, End: 1, Text: "b"}},
"chunk00002.wav": {{Start: 0, End: 1, Text: "c"}},
},
}
tr := newTestTranscriber(client, 3, &dir)
var chunkOrder []string
cues, _, err := tr.Transcribe(context.Background(), TranscribeJobRequest{
FilePath: "/x.mkv", StartPosition: 1300, // inside chunk 2
}, func(chunk []SubtitleCue, done, total int) {
if total != 3 {
t.Errorf("total = %d, want 3", total)
}
chunkOrder = append(chunkOrder, strings.Join(chunk[0].Lines, ""))
})
if err != nil {
t.Fatalf("Transcribe: %v", err)
}
if got := strings.Join(chunkOrder, ""); got != "cab" {
t.Errorf("chunk processing order = %q, want cab (playhead-first, wrapping)", got)
}
if len(cues) != 3 {
t.Errorf("cues = %d, want 3", len(cues))
}
}
func TestTranscribePassesHintAndChunkSizedTimeout(t *testing.T) {
var dir string
client := &fakeASRClient{
language: "fr",
perChunk: map[string][]llm.TranscriptionSegment{
"chunk00000.wav": {{Start: 0, End: 1, Text: "bonjour"}},
},
}
tr := newTestTranscriber(client, 1, &dir)
_, _, err := tr.Transcribe(context.Background(), TranscribeJobRequest{FilePath: "/x.mkv", LanguageHint: "fr"}, nil)
if err != nil {
t.Fatalf("Transcribe: %v", err)
}
req := client.requests[0]
if req.Language != "fr" {
t.Errorf("hint = %q, want fr", req.Language)
}
if want := 1800 * time.Second; req.Timeout != want {
t.Errorf("timeout = %v, want %v (3x chunk duration)", req.Timeout, want)
}
}
func TestTranscribeAllSilentChunksFailsClearly(t *testing.T) {
var dir string
client := &fakeASRClient{language: "en", perChunk: map[string][]llm.TranscriptionSegment{}}
tr := newTestTranscriber(client, 2, &dir)
_, _, err := tr.Transcribe(context.Background(), TranscribeJobRequest{FilePath: "/x.mkv"}, nil)
if err == nil || !strings.Contains(err.Error(), "no speech") {
t.Fatalf("err = %v, want no-speech failure", err)
}
if _, statErr := os.Stat(dir); !os.IsNotExist(statErr) {
t.Errorf("temp dir %s not cleaned up on failure", dir)
}
}
func TestWrapCueText(t *testing.T) {
cases := []struct {
text string
want []string
}{
{"short line", []string{"short line"}},
{"", nil},
{
"this sentence is long enough that it needs to wrap onto a second line",
[]string{"this sentence is long enough that it", "needs to wrap onto a second line"},
},
}
for _, c := range cases {
got := wrapCueText(c.text, 42, 2)
if fmt.Sprint(got) != fmt.Sprint(c.want) {
t.Errorf("wrapCueText(%q) = %q, want %q", c.text, got, c.want)
}
}
// Overflow beyond two lines is absorbed, never dropped.
long := strings.Repeat("word ", 40)
got := wrapCueText(strings.TrimSpace(long), 42, 2)
if len(got) != 2 {
t.Fatalf("lines = %d, want 2", len(got))
}
if joined := strings.Join(got, " "); strings.Count(joined, "word") != 40 {
t.Errorf("overflow dropped words: %d/40", strings.Count(joined, "word"))
}
}
func TestCuesFromSegmentsCapsWordlessSegmentDuration(t *testing.T) {
// Without word timings, Whisper segment ends stretch wall-to-wall across
// silence (a 0.8s line reported as 30s); cap rather than linger.
cues := cuesFromSegments([]llm.TranscriptionSegment{
{Start: 10, End: 40, Text: "Days like today."},
}, 0)
if len(cues) != 1 {
t.Fatalf("cues = %d, want 1", len(cues))
}
if want := time.Duration(maxCueSeconds * float64(time.Second)); cues[0].End-cues[0].Start != want {
t.Errorf("capped duration = %v, want %v", cues[0].End-cues[0].Start, want)
}
}
func wordSeq(startAt, dur, gap float64, words ...string) []llm.TranscriptionWord {
out := make([]llm.TranscriptionWord, 0, len(words))
at := startAt
for _, w := range words {
out = append(out, llm.TranscriptionWord{Start: at, End: at + dur, Text: " " + w})
at += dur + gap
}
return out
}
func TestCuesFromWordsEndsCueWhenSpeechStops(t *testing.T) {
// One segment whose reported end (60s) is far past the last word (12.4s):
// the cue must end at the words, not the segment.
words := wordSeq(10, 0.4, 0.1, "Are", "you", "okay?")
cues := cuesFromSegments([]llm.TranscriptionSegment{
{Start: 10, End: 60, Text: " Are you okay?", Words: words},
}, 0)
if len(cues) != 1 {
t.Fatalf("cues = %d, want 1", len(cues))
}
if cues[0].Start != 10*time.Second {
t.Errorf("start = %v, want 10s", cues[0].Start)
}
// Last word ends at 11.3s; the minimum-duration stretch may pad slightly,
// but nothing close to the segment's reported 60s.
if cues[0].End > 12*time.Second {
t.Errorf("end = %v, want ~11.3s (last word), not segment end", cues[0].End)
}
}
func TestCuesFromWordsSplitsAtPause(t *testing.T) {
words := append(wordSeq(0, 0.4, 0.1, "First", "thought"),
wordSeq(5, 0.4, 0.1, "second", "thought")...)
cues := cuesFromWords(words, 0)
if len(cues) != 2 {
t.Fatalf("cues = %d, want 2 (split at 4s pause)", len(cues))
}
if cues[1].Start != 5*time.Second {
t.Errorf("second cue start = %v, want 5s", cues[1].Start)
}
}
func TestCuesFromWordsSplitsParagraphAtSentencesAndCapacity(t *testing.T) {
// A paragraph-length segment (the "465-char single cue" failure) must
// split into readable cues. Sentence ends close a cue once a line's worth
// of text has accumulated; capacity closes one regardless.
var words []llm.TranscriptionWord
for i := 0; i < 6; i++ {
words = append(words, wordSeq(float64(i)*3, 0.3, 0.1,
"this", "sentence", "keeps", "going", "and", "going", "until", "it", "stops.")...)
}
cues := cuesFromWords(words, 0)
if len(cues) < 4 {
t.Fatalf("cues = %d, want the paragraph split into several", len(cues))
}
maxRunes := cueMaxLineLength * cueMaxLines
for i, c := range cues {
text := strings.Join(c.Lines, " ")
if got := len([]rune(text)); got > maxRunes {
t.Errorf("cue %d has %d runes, over capacity %d: %q", i, got, maxRunes, text)
}
if dur := c.End - c.Start; dur > time.Duration(maxCueSeconds*float64(time.Second))+time.Second {
t.Errorf("cue %d duration %v exceeds max", i, dur)
}
}
}
func TestEnforceMinCueDurationsClampsToNextCue(t *testing.T) {
cues := []SubtitleCue{
{Start: 0, End: 200 * time.Millisecond, Lines: []string{"What?"}},
{Start: 600 * time.Millisecond, End: 2 * time.Second, Lines: []string{"LSD."}},
{Start: 3 * time.Second, End: 3200 * time.Millisecond, Lines: []string{"Oh."}},
}
enforceMinCueDurations(cues)
if cues[0].End != 600*time.Millisecond {
t.Errorf("cue 0 end = %v, want clamped to next cue start (600ms)", cues[0].End)
}
if cues[1].End != 2*time.Second {
t.Errorf("cue 1 end = %v, want unchanged", cues[1].End)
}
if cues[2].End != 4*time.Second {
t.Errorf("cue 2 end = %v, want stretched to 1s minimum", cues[2].End)
}
}
func TestCuesFromSegmentsGuardsDegenerateTimes(t *testing.T) {
cues := cuesFromSegments([]llm.TranscriptionSegment{
{Start: 5, End: 5, Text: "zero duration"},
{Start: 1, End: 2, Text: " "},
}, 0)
if len(cues) != 1 {
t.Fatalf("cues = %d, want 1 (whitespace dropped)", len(cues))
}
if cues[0].End <= cues[0].Start {
t.Errorf("degenerate cue not given a minimum duration: %v–%v", cues[0].Start, cues[0].End)
}
}
func TestChunkOrderForPosition(t *testing.T) {
chunks := []playback.AudioChunk{{Start: 0}, {Start: 600.06}, {Start: 1200.13}, {Start: 1800.2}}
if got := fmt.Sprint(chunkOrderForPosition(chunks, 0)); got != "[0 1 2 3]" {
t.Errorf("no playhead: %s", got)
}
if got := fmt.Sprint(chunkOrderForPosition(chunks, 1900)); got != "[3 0 1 2]" {
t.Errorf("late playhead: %s", got)
}
if got := fmt.Sprint(chunkOrderForPosition(chunks, 99999)); got != "[3 0 1 2]" {
t.Errorf("beyond-last playhead starts at the final chunk: %s", got)
}
}
// Cue timing must use the muxer-reported chunk start (not index*chunkSeconds)
// plus the probed audio start offset, or sync drifts on long files and
// delayed-audio containers.
func TestTranscribeUsesExactChunkStartsAndAudioOffset(t *testing.T) {
var dir string
client := &fakeASRClient{
language: "en",
perChunk: map[string][]llm.TranscriptionSegment{
"chunk00000.wav": {{Start: 1, End: 2, Text: "first"}},
"chunk00001.wav": {{Start: 1, End: 2, Text: "second"}},
},
}
tr := &WhisperTranscriber{
client: client,
chunkSeconds: 600,
// The second chunk really starts at 600.5s, not 600s.
extract: stubExtract([]float64{0, 600.5}, &dir),
probeOffset: func(context.Context, string, int, string) float64 { return 1.25 },
}
cues, _, err := tr.Transcribe(context.Background(), TranscribeJobRequest{FilePath: "/x.mkv"}, nil)
if err != nil {
t.Fatalf("Transcribe: %v", err)
}
if want := time.Duration(2.25 * float64(time.Second)); cues[0].Start != want {
t.Errorf("cue 0 start = %v, want %v (1s segment + 1.25s audio offset)", cues[0].Start, want)
}
if want := time.Duration(602.75 * float64(time.Second)); cues[1].Start != want {
t.Errorf("cue 1 start = %v, want %v (600.5s chunk + 1s segment + 1.25s offset)", cues[1].Start, want)
}
}
func TestNormalizeDetectedLanguage(t *testing.T) {
cases := map[string]string{
"english": "en",
"en": "en",
"eng": "en",
"JAPANESE": "ja",
"": "",
"klingon": "",
}
for in, want := range cases {
if got := normalizeDetectedLanguage(in); got != want {
t.Errorf("normalizeDetectedLanguage(%q) = %q, want %q", in, got, want)
}
}
}
+44 -149
View File
@@ -2,30 +2,29 @@ package ai
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/Silo-Server/silo-server/internal/ai/llm"
aitranslate "github.com/Silo-Server/silo-server/internal/ai/translate"
)
// LLMTranslator translates subtitle cues with an OpenAI-compatible chat model.
//
// Cues are translated in batches. Each batch is sent as a JSON object keyed by
// cue number; the model must return the same keys with translated values. Only
// the text is sent to the model — timestamps never leave the server — so timing
// alignment is structurally guaranteed. A few preceding source cues are
// included as untranslated context so the model can keep scene continuity
// across batch boundaries.
// LLMTranslator translates subtitle cues with an OpenAI-compatible chat model
// via the shared aitranslate batch protocol. Only the text is sent to the
// model — timestamps never leave the server — so timing alignment is
// structurally guaranteed. A few preceding source cues are included as
// untranslated context so the model can keep scene continuity across batch
// boundaries.
type LLMTranslator struct {
client *Client
client *llm.Client
batchSize int
contextNeighbors int
maxRetries int
}
// NewLLMTranslator builds a translator. batchSize and contextNeighbors fall back
// to sane defaults when non-positive.
func NewLLMTranslator(client *Client, batchSize, contextNeighbors int) *LLMTranslator {
// NewLLMTranslator builds a translator. batchSize and contextNeighbors fall
// back to sane defaults when non-positive.
func NewLLMTranslator(client *llm.Client, batchSize, contextNeighbors int) *LLMTranslator {
if batchSize <= 0 {
batchSize = 40
}
@@ -36,7 +35,6 @@ func NewLLMTranslator(client *Client, batchSize, contextNeighbors int) *LLMTrans
client: client,
batchSize: batchSize,
contextNeighbors: contextNeighbors,
maxRetries: 2,
}
}
@@ -45,9 +43,6 @@ func (t *LLMTranslator) Translate(ctx context.Context, req TranslateRequest, onB
if t.client == nil {
return nil, fmt.Errorf("translator client is nil")
}
if t.batchSize <= 0 {
return nil, fmt.Errorf("invalid batch size: %d", t.batchSize)
}
if strings.TrimSpace(req.TargetLanguage) == "" {
return nil, fmt.Errorf("target language is required")
}
@@ -60,97 +55,48 @@ func (t *LLMTranslator) Translate(ctx context.Context, req TranslateRequest, onB
out := make([]SubtitleCue, total)
copy(out, req.Cues)
srcName := languageDisplayName(req.SourceLanguage)
tgtName := languageDisplayName(req.TargetLanguage)
system := translationSystemPrompt(srcName, tgtName)
segments := make([]aitranslate.Segment, total)
for i, c := range req.Cues {
segments[i] = aitranslate.Segment{ID: strconv.Itoa(i + 1), Text: strings.Join(c.Lines, "\n")}
}
for start := 0; start < total; start += t.batchSize {
if err := ctx.Err(); err != nil {
return nil, err
}
end := min(start+t.batchSize, total)
srcName := aitranslate.LanguageDisplayName(req.SourceLanguage)
tgtName := aitranslate.LanguageDisplayName(req.TargetLanguage)
contextStart := max(0, start-t.contextNeighbors)
translated, err := t.translateBatch(ctx, system, tgtName, req.Cues[contextStart:start], req.Cues[start:end])
if err != nil {
return nil, fmt.Errorf("translate cues %d-%d: %w", start+1, end, err)
chat := func(ctx context.Context, system, user string) (string, error) {
messages := []llm.Message{
{Role: "system", Content: system},
{Role: "user", Content: user},
}
for i, lines := range translated {
out[start+i].Lines = lines
return t.client.Chat(ctx, messages, true)
}
translated, err := aitranslate.Translate(ctx, chat, aitranslate.Request{
Segments: segments,
SystemPrompt: translationSystemPrompt(srcName, tgtName),
TargetName: tgtName,
EntryNoun: "cues",
BatchSize: t.batchSize,
ContextNeighbors: t.contextNeighbors,
}, func(batch []aitranslate.Segment, done, totalSegs int) {
// Batches are sequential ranges, so this batch covers [done-len, done).
start := done - len(batch)
for i, seg := range batch {
out[start+i].Lines = splitCueLines(seg.Text)
}
if onBatch != nil {
onBatch(out[start:end], end, total)
onBatch(out[start:done], done, totalSegs)
}
}
return out, nil
}
func (t *LLMTranslator) translateBatch(ctx context.Context, system, targetName string, contextCues, batch []SubtitleCue) ([][]string, error) {
texts := make([]string, len(batch))
for i, c := range batch {
texts[i] = strings.Join(c.Lines, "\n")
}
payload, err := buildIndexedJSON(texts)
})
if err != nil {
return nil, err
}
var user strings.Builder
if len(contextCues) > 0 {
user.WriteString("Preceding lines for context only — do not translate or include them in your output:\n")
for _, c := range contextCues {
user.WriteString(strings.Join(c.Lines, " "))
user.WriteByte('\n')
}
user.WriteByte('\n')
// Map the full result too: covers callers without an onBatch callback.
for i, seg := range translated {
out[i].Lines = splitCueLines(seg.Text)
}
fmt.Fprintf(&user, "Translate these %d cues into %s. Respond with only the JSON object:\n%s", len(batch), targetName, payload)
messages := []chatMessage{
{Role: "system", Content: system},
{Role: "user", Content: user.String()},
}
var lastErr error
for attempt := 0; attempt <= t.maxRetries; attempt++ {
if err := ctx.Err(); err != nil {
return nil, err
}
content, err := t.client.chat(ctx, messages, true)
if err != nil {
return nil, err // transport/API errors are already retried inside chat
}
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] = splitCueLines(v)
}
if !complete {
lastErr = fmt.Errorf("model omitted one or more cues")
continue
}
return out, nil
}
return nil, fmt.Errorf("invalid model response after %d attempts: %w", t.maxRetries+1, lastErr)
return out, nil
}
func translationSystemPrompt(srcName, tgtName string) string {
@@ -169,57 +115,6 @@ func translationSystemPrompt(srcName, tgtName string) string {
)
}
// buildIndexedJSON renders texts as a JSON object {"1":..., "2":...} keyed by
// 1-based cue number, 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 scene 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
}
func splitCueLines(v string) []string {
lines := strings.Split(v, "\n")
for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
+9 -6
View File
@@ -20,7 +20,7 @@ func scanProfile(scanner interface {
var createdAt, updatedAt time.Time
err := scanner.Scan(
&p.ID, &p.Name, &p.Avatar, &p.PINHash, &p.IsChild, &p.IsPrimary, &p.MaxContentRating,
&p.QualityPreference, &p.Language, &p.SubtitleLanguage, &p.SubtitleMode,
&p.QualityPreference, &p.Language, &p.PreferredMetadataLanguage, &p.SubtitleLanguage, &p.SubtitleMode,
&p.AutoSkipIntro, &p.AutoSkipCredits, &p.AutoSkipRecap, &p.AutoPlayNextPreview,
&p.LibraryRestrictionsEnabled,
&p.ShowForcedSubtitles, &p.MaxPlaybackQuality, &createdAt, &updatedAt,
@@ -66,13 +66,13 @@ func (s *PostgresUserStore) CreateProfile(ctx context.Context, p userstore.Profi
_, err := s.pool.Exec(ctx, `
INSERT INTO user_profiles (
id, user_id, name, avatar, pin_hash, is_child, is_primary, max_content_rating,
quality_preference, language, subtitle_language, subtitle_mode,
quality_preference, language, preferred_metadata_language, subtitle_language, subtitle_mode,
auto_skip_intro, auto_skip_credits, auto_skip_recap, auto_play_next_preview,
library_restrictions_enabled,
show_forced_subtitles, max_playback_quality, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)`,
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)`,
p.ID, s.userID, p.Name, p.Avatar, p.PINHash, p.IsChild, p.IsPrimary, p.MaxContentRating,
p.QualityPreference, p.Language, p.SubtitleLanguage, p.SubtitleMode,
p.QualityPreference, p.Language, p.PreferredMetadataLanguage, p.SubtitleLanguage, p.SubtitleMode,
p.AutoSkipIntro, p.AutoSkipCredits, p.AutoSkipRecap, p.AutoPlayNextPreview,
p.LibraryRestrictionsEnabled,
p.ShowForcedSubtitles, p.MaxPlaybackQuality, p.CreatedAt, p.UpdatedAt,
@@ -89,7 +89,7 @@ func (s *PostgresUserStore) CreateProfile(ctx context.Context, p userstore.Profi
func (s *PostgresUserStore) GetProfile(ctx context.Context, id string) (*userstore.Profile, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, name, avatar, pin_hash, is_child, is_primary, max_content_rating,
quality_preference, language, subtitle_language, subtitle_mode,
quality_preference, language, preferred_metadata_language, subtitle_language, subtitle_mode,
auto_skip_intro, auto_skip_credits, auto_skip_recap, auto_play_next_preview, library_restrictions_enabled,
show_forced_subtitles, max_playback_quality, created_at, updated_at
FROM user_profiles WHERE user_id = $1 AND id = $2`, s.userID, id)
@@ -111,7 +111,7 @@ func (s *PostgresUserStore) GetProfile(ctx context.Context, id string) (*usersto
func (s *PostgresUserStore) ListProfiles(ctx context.Context) ([]userstore.Profile, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, name, avatar, pin_hash, is_child, is_primary, max_content_rating,
quality_preference, language, subtitle_language, subtitle_mode,
quality_preference, language, preferred_metadata_language, subtitle_language, subtitle_mode,
auto_skip_intro, auto_skip_credits, auto_skip_recap, auto_play_next_preview, library_restrictions_enabled,
show_forced_subtitles, max_playback_quality, created_at, updated_at
FROM user_profiles WHERE user_id = $1 ORDER BY created_at ASC`, s.userID)
@@ -185,6 +185,9 @@ func (s *PostgresUserStore) UpdateProfile(ctx context.Context, id string, u user
if u.Language != nil {
addArg("language", *u.Language)
}
if u.PreferredMetadataLanguage != nil {
addArg("preferred_metadata_language", *u.PreferredMetadataLanguage)
}
if u.SubtitleLanguage != nil {
addArg("subtitle_language", *u.SubtitleLanguage)
}
+2
View File
@@ -13,6 +13,7 @@ type Profile struct {
MaxContentRating string
QualityPreference string
Language string
PreferredMetadataLanguage string // ISO 639-1; "" = inherit library metadata language
SubtitleLanguage string
SubtitleMode string
AutoSkipIntro bool
@@ -58,6 +59,7 @@ type UpdateProfileInput struct {
MaxContentRating *string
QualityPreference *string
Language *string
PreferredMetadataLanguage *string
SubtitleLanguage *string
SubtitleMode *string
AutoSkipIntro *bool
@@ -0,0 +1,73 @@
-- +goose Up
-- +goose StatementBegin
-- AI metadata translation jobs: on-demand (and per-library auto-fallback)
-- translation of overviews/taglines into the localization tables. Each row is
-- one job covering a media item (optionally expanded to its seasons/episodes),
-- one season, or one episode. Results are ordinary localization rows, so they
-- reach every client through the existing localized detail responses.
CREATE TABLE public.metadata_translation_jobs (
id bigserial PRIMARY KEY,
target_kind text NOT NULL, -- 'item' | 'season' | 'episode'
content_id text NOT NULL,
include_children boolean NOT NULL DEFAULT true, -- series item: include season/episode overviews
source_language text NOT NULL DEFAULT '',
target_language text NOT NULL,
engine text NOT NULL DEFAULT 'openai',
model text NOT NULL DEFAULT '', -- snapshot of the model used, for provenance
status text NOT NULL DEFAULT 'pending', -- pending|running|completed|failed|cancelled
progress double precision NOT NULL DEFAULT 0, -- 0..1
progress_message text NOT NULL DEFAULT '',
fields_done integer NOT NULL DEFAULT 0,
fields_total integer NOT NULL DEFAULT 0,
force boolean NOT NULL DEFAULT false, -- overwrite provider/ai fields (never manual)
error_message text NOT NULL DEFAULT '',
idempotency_key text NOT NULL,
requested_by integer,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
heartbeat_at timestamptz NOT NULL DEFAULT now()
);
-- Prevent duplicate in-flight jobs for the same work. Completed/failed/cancelled
-- rows do not block a re-run, so a user can retry after a failure.
CREATE UNIQUE INDEX metadata_translation_jobs_active_idempotency_idx
ON public.metadata_translation_jobs (idempotency_key)
WHERE status IN ('pending', 'running');
-- Listing recent jobs for an item (metadata editor progress UI, admin views).
CREATE INDEX metadata_translation_jobs_content_idx
ON public.metadata_translation_jobs (content_id, created_at DESC);
-- Startup recovery scan for jobs left running by a crashed process.
CREATE INDEX metadata_translation_jobs_status_idx
ON public.metadata_translation_jobs (status) WHERE status IN ('pending', 'running');
-- Field provenance on the AI-writable localization fields, so refreshes never
-- regress quality: 'manual' beats 'provider' beats 'ai'. A provider refresh
-- overwrites an AI translation; the reverse never happens.
ALTER TABLE media_item_localizations
ADD COLUMN overview_source text NOT NULL DEFAULT 'provider'
CHECK (overview_source IN ('provider', 'ai', 'manual')),
ADD COLUMN tagline_source text NOT NULL DEFAULT 'provider'
CHECK (tagline_source IN ('provider', 'ai', 'manual'));
ALTER TABLE season_localizations
ADD COLUMN overview_source text NOT NULL DEFAULT 'provider'
CHECK (overview_source IN ('provider', 'ai', 'manual'));
ALTER TABLE episode_localizations
ADD COLUMN overview_source text NOT NULL DEFAULT 'provider'
CHECK (overview_source IN ('provider', 'ai', 'manual'));
-- Per-library opt-in: when metadata providers have no localization for the
-- library's metadata language, fall back to AI translation on refresh.
ALTER TABLE media_folders
ADD COLUMN auto_translate_metadata boolean NOT NULL DEFAULT false;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE media_folders DROP COLUMN auto_translate_metadata;
ALTER TABLE episode_localizations DROP COLUMN overview_source;
ALTER TABLE season_localizations DROP COLUMN overview_source;
ALTER TABLE media_item_localizations DROP COLUMN tagline_source, DROP COLUMN overview_source;
DROP TABLE IF EXISTS public.metadata_translation_jobs;
-- +goose StatementEnd
@@ -0,0 +1,13 @@
-- +goose Up
-- +goose StatementBegin
-- Per-profile metadata (presentation) language. Empty = inherit the library's
-- metadata_language. When set, catalog serving prefers this language's
-- localizations, and the on-view AI translation flow targets it.
ALTER TABLE user_profiles
ADD COLUMN preferred_metadata_language text NOT NULL DEFAULT '';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE user_profiles DROP COLUMN preferred_metadata_language;
-- +goose StatementEnd
+10
View File
@@ -125,6 +125,7 @@ export interface Profile {
max_content_rating: string;
quality_preference: string;
language: string;
preferred_metadata_language?: string;
subtitle_language: string;
subtitle_mode: string;
show_forced_subtitles?: boolean;
@@ -152,6 +153,7 @@ export interface CreateProfileRequest {
max_content_rating?: string;
quality_preference?: string;
language?: string;
preferred_metadata_language?: string;
subtitle_language?: string;
subtitle_mode?: string;
show_forced_subtitles?: boolean;
@@ -958,6 +960,12 @@ export interface ItemDetail {
year: number;
overview: string;
tagline?: string;
/**
* When set, the viewer's presentation language is missing a localized
* description; on-view AI translation (auto or button, per server config)
* keys off this.
*/
pending_translation_language?: string;
runtime: number;
content_rating: string;
genres: string[];
@@ -2364,6 +2372,7 @@ export interface Library {
name: string;
enabled: boolean;
metadata_language: string;
auto_translate_metadata: boolean;
chapter_thumbnails_enabled: boolean;
chapter_thumbnails_supported: boolean;
intro_detection_enabled: boolean;
@@ -2534,6 +2543,7 @@ export interface CreateLibraryRequest {
name: string;
enabled?: boolean;
metadata_language?: string;
auto_translate_metadata?: boolean;
chapter_thumbnails_enabled?: boolean;
intro_detection_enabled?: boolean;
}
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { MetadataTranslatePanel } from "@/components/MetadataTranslatePanel";
import { TagInput } from "@/components/TagInput";
import ImageSelectorTab from "@/components/ImageSelectorTab";
import type { ItemDetail } from "@/api/types";
@@ -340,6 +341,10 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
/>
</FieldRow>
{(item.type === "movie" || item.type === "series") && (
<MetadataTranslatePanel item={item} />
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FieldRow label="Content Rating" lockIcon={renderLockIcon("content_rating")}>
<Input
@@ -0,0 +1,134 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Languages } from "lucide-react";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import type { ItemDetail } from "@/api/types";
import {
useMetadataAIStatus,
useMetadataTranslationJobs,
useTranslateItemMetadata,
type MetadataTranslationJob,
} from "@/hooks/queries/items";
import { invalidateMediaSurfaceQueries } from "@/hooks/queries/mediaSurfaceRefresh";
import { LANGUAGES } from "@/player/utils/languageNames";
function isActive(job: MetadataTranslationJob): boolean {
return job.status === "pending" || job.status === "running";
}
/**
* "Translate with AI" block for the metadata editor: translates the item's
* overview/tagline (and, for series, all season/episode overviews) into a
* chosen language. Results land in the localization tables marked as
* AI-sourced, so a later provider refresh can replace them but never the
* other way around. Rendered only when the server has metadata AI enabled.
*/
export function MetadataTranslatePanel({ item }: { item: ItemDetail }) {
const queryClient = useQueryClient();
const { data: status } = useMetadataAIStatus();
const enabled = Boolean(status?.enabled);
const [targetLang, setTargetLang] = useState("");
const [force, setForce] = useState(false);
const [watching, setWatching] = useState(false);
const translateMutation = useTranslateItemMetadata(item.content_id);
const { data: jobsData } = useMetadataTranslationJobs(item.content_id, enabled && watching);
const activeJob = useMemo(() => (jobsData?.jobs ?? []).find(isActive), [jobsData]);
const lastJob = jobsData?.jobs?.[0];
// When the job we were watching reaches a terminal state, surface the
// outcome and refresh the (now possibly re-localized) detail surfaces.
const sawActiveRef = useRef(false);
useEffect(() => {
if (activeJob) {
sawActiveRef.current = true;
return;
}
if (!sawActiveRef.current || !lastJob) return;
sawActiveRef.current = false;
setWatching(false);
if (lastJob.status === "completed") {
toast.success(
lastJob.fields_total === 0
? "Nothing to translate — all descriptions are already localized."
: `Translated ${lastJob.fields_done} description${lastJob.fields_done === 1 ? "" : "s"}.`,
);
void invalidateMediaSurfaceQueries(queryClient, { itemId: item.content_id });
} else if (lastJob.status === "failed") {
toast.error(lastJob.error_message || "Translation failed.");
}
}, [activeJob, lastJob, queryClient, item.content_id]);
if (!enabled) return null;
const busy = translateMutation.isPending || Boolean(activeJob);
function start() {
if (!targetLang) {
toast.error("Pick a target language first.");
return;
}
translateMutation.mutate(
{ target_language: targetLang, include_children: true, force },
{ onSuccess: () => setWatching(true) },
);
}
return (
<div className="border-border bg-muted/30 space-y-3 rounded-md border px-3 py-3">
<div className="flex items-center gap-2">
<Languages className="text-muted-foreground h-4 w-4" />
<span className="text-sm font-medium">Translate with AI</span>
</div>
<p className="text-muted-foreground text-xs">
Translates the overview and tagline
{item.type === "series" ? ", plus all season and episode overviews," : ""} into the chosen
language. Translations are served to libraries using that metadata language; provider data
replaces them when it becomes available.
</p>
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<Label htmlFor="translate-target" className="text-xs">
Language
</Label>
<select
id="translate-target"
className="border-border bg-background text-foreground h-9 rounded-md border px-2 text-sm"
value={targetLang}
onChange={(e) => setTargetLang(e.target.value)}
disabled={busy}
>
<option value="">Select…</option>
{LANGUAGES.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.label}
</option>
))}
</select>
</div>
<div className="flex h-9 items-center gap-2">
<Switch id="translate-force" checked={force} onCheckedChange={setForce} disabled={busy} />
<Label htmlFor="translate-force" className="text-xs">
Re-translate existing
</Label>
</div>
<Button type="button" size="sm" onClick={start} disabled={busy}>
{busy ? "Translating…" : "Translate"}
</Button>
</div>
{activeJob && (
<p className="text-muted-foreground text-xs">
{activeJob.progress_message || "Working"}…{" "}
{activeJob.fields_total > 0 &&
`${activeJob.fields_done}/${activeJob.fields_total} fields`}
</p>
)}
</div>
);
}
@@ -286,6 +286,21 @@ export function MetadataFields({ form }: { form: LibraryFormController }) {
Preferred language for titles, summaries, and artwork fetched from providers.
</p>
</div>
<div className="flex items-start justify-between gap-4">
<div className="space-y-0.5">
<Label htmlFor="auto-translate-metadata">Auto-translate descriptions</Label>
<p className="text-muted-foreground text-xs">
When providers have no translation for this library&apos;s language, translate
descriptions with AI after each refresh. Requires AI description translation in Admin
Settings → AI Services.
</p>
</div>
<Switch
id="auto-translate-metadata"
checked={form.autoTranslateMetadata}
onCheckedChange={form.setAutoTranslateMetadata}
/>
</div>
{form.contentLevels.length > 0 && (
<div className="space-y-1.5">
<Label>Provider Priority</Label>
@@ -147,6 +147,9 @@ export function useLibraryForm({
const [type, setType] = useState(library?.type ?? "movies");
const [enabled, setEnabled] = useState(library?.enabled ?? true);
const [metadataLanguage, setMetadataLanguage] = useState(library?.metadata_language ?? "en");
const [autoTranslateMetadata, setAutoTranslateMetadata] = useState(
library?.auto_translate_metadata ?? false,
);
const [chapterThumbnailsEnabled, setChapterThumbnailsEnabled] = useState(
library?.chapter_thumbnails_enabled ?? false,
);
@@ -284,6 +287,7 @@ export function useLibraryForm({
type,
enabled,
metadata_language: metadataLanguage,
auto_translate_metadata: autoTranslateMetadata,
chapter_thumbnails_enabled: chapterThumbnailsEnabled,
intro_detection_enabled: introDetectionEnabled,
};
@@ -343,6 +347,8 @@ export function useLibraryForm({
setEnabled,
metadataLanguage,
setMetadataLanguage,
autoTranslateMetadata,
setAutoTranslateMetadata,
chapterThumbnailsEnabled,
setChapterThumbnailsEnabled,
introDetectionEnabled,
+78
View File
@@ -431,3 +431,81 @@ export function useApplyItemImage() {
},
});
}
// ---------------------------------------------------------------------------
// Metadata AI translation (descriptions into the localization tables)
// ---------------------------------------------------------------------------
export interface MetadataTranslationJob {
id: number;
target_kind: string;
content_id: string;
include_children: boolean;
source_language: string;
target_language: string;
status: "pending" | "running" | "completed" | "failed" | "cancelled";
progress: number;
progress_message: string;
fields_done: number;
fields_total: number;
force: boolean;
error_message?: string;
created_at: string;
updated_at: string;
}
/** Whether the server has metadata AI translation configured, and the
* viewer-facing on-view mode. */
export function useMetadataAIStatus(enabled = true) {
return useQuery({
queryKey: ["metadata-ai", "status"],
queryFn: () =>
api<{ enabled: boolean; on_view?: "off" | "button" | "auto" }>("/metadata/ai/status"),
staleTime: 5 * 60 * 1000,
enabled,
});
}
export interface TranslateItemMetadataRequest {
target_language: string;
include_children?: boolean;
force?: boolean;
}
export function useTranslateItemMetadata(contentId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: TranslateItemMetadataRequest) =>
api<{ job: MetadataTranslationJob }>(
`/admin/items/${itemPathID(contentId)}/metadata-translation`,
{ method: "POST", body: JSON.stringify(body) },
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["metadata-translation-jobs", contentId],
});
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to start translation");
},
});
}
/**
* Recent translation jobs for an item. Polls while a job is active so the
* metadata editor can show live progress without a websocket.
*/
export function useMetadataTranslationJobs(contentId: string, enabled: boolean) {
return useQuery({
queryKey: ["metadata-translation-jobs", contentId],
queryFn: () =>
api<{ jobs: MetadataTranslationJob[] }>(
`/admin/items/${itemPathID(contentId)}/metadata-translation/jobs`,
),
enabled,
refetchInterval: (query) => {
const jobs = query.state.data?.jobs ?? [];
return jobs.some((j) => j.status === "pending" || j.status === "running") ? 1500 : false;
},
});
}
+85
View File
@@ -0,0 +1,85 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { api } from "@/api/client";
import type { ItemDetail } from "@/api/types";
import { useMetadataAIStatus } from "@/hooks/queries/items";
// Give up shimmering after this long; the original text stays and the
// server-side cooldown keeps a failing endpoint from being re-hit per view.
const TRANSLATE_TIMEOUT_MS = 45_000;
const POLL_INTERVAL_MS = 2_000;
/**
* Viewer-facing on-demand description translation for the detail page.
*
* When the detail response carries `pending_translation_language` (the
* description is not in this profile's language and no localization exists),
* the server's `metadata_ai.on_view` mode decides the UX:
* - "auto": fire the translation on view (once per item+language) and pulse
* the description until the refetched detail comes back localized.
* - "button": expose a translate trigger for the chip under the overview.
* Completion is observed as the flag clearing on refetch — the job's first
* batch translates the item's own overview, so this lands in seconds.
*/
export function useOnViewTranslation(item: ItemDetail | undefined) {
const queryClient = useQueryClient();
const { data: status } = useMetadataAIStatus();
const mode = status?.on_view ?? "off";
const contentId = item?.content_id ?? "";
const pendingLanguage = item?.pending_translation_language ?? "";
const [translating, setTranslating] = useState(false);
// Tracks which item+language we already fired for, so auto mode triggers
// once per view rather than re-firing on every refetch while polling.
const firedForRef = useRef("");
const trigger = useCallback(() => {
if (!contentId || !pendingLanguage) return;
const key = `${contentId}:${pendingLanguage}`;
if (firedForRef.current === key) return;
firedForRef.current = key;
setTranslating(true);
api(`/items/${encodeURIComponent(contentId)}/translate-description`, {
method: "POST",
body: JSON.stringify({ target_language: pendingLanguage }),
}).catch(() => {
setTranslating(false);
});
}, [contentId, pendingLanguage]);
// Auto mode: translate on view.
useEffect(() => {
if (mode === "auto" && pendingLanguage) trigger();
}, [mode, pendingLanguage, trigger]);
// While translating, poll the detail; the localized overview replaces the
// text and clears the pending flag, which ends the shimmer below.
useEffect(() => {
if (!translating || !contentId) return;
const startedAt = Date.now();
const timer = setInterval(() => {
if (Date.now() - startedAt > TRANSLATE_TIMEOUT_MS) {
setTranslating(false);
return;
}
// Prefix invalidation covers the per-library detail key variants
// (["catalog", "items", id, "detail", <libraryId|"default">]).
void queryClient.invalidateQueries({ queryKey: ["catalog", "items", contentId] });
}, POLL_INTERVAL_MS);
return () => clearInterval(timer);
}, [translating, contentId, queryClient]);
// The refetched detail no longer reports a missing language: done.
useEffect(() => {
if (translating && !pendingLanguage) setTranslating(false);
}, [translating, pendingLanguage]);
return {
/** Pulse the description text. */
translating,
/** Render the explicit translate chip (button mode only). */
onTranslate: mode === "button" && pendingLanguage && !translating ? trigger : undefined,
};
}
+32 -7
View File
@@ -1,4 +1,5 @@
import { type ReactNode, useState } from "react";
import { Languages } from "lucide-react";
import { decodeThumbhash } from "@/lib/thumbhash";
interface DetailHeroProps {
@@ -21,6 +22,10 @@ interface DetailHeroProps {
*/
genreHref?: (genre: string) => string;
overview?: string;
/** Pulses the overview while an on-view AI translation is in flight. */
overviewTranslating?: boolean;
/** When set, renders a small "Translate" chip under the overview. */
onTranslateOverview?: () => void;
actions?: ReactNode;
aside?: ReactNode;
studioLabel?: string;
@@ -46,6 +51,8 @@ export default function DetailHero({
genres,
genreHref,
overview,
overviewTranslating = false,
onTranslateOverview,
actions,
aside,
studioLabel,
@@ -227,13 +234,31 @@ export default function DetailHero({
{scoreRow && <div className="mb-4">{scoreRow}</div>}
{overview && (
<p
className={`text-muted-foreground max-w-2xl leading-7 ${
isCompact ? "text-sm" : "text-foreground/72 text-sm sm:text-[15px]"
}`}
>
{overview}
</p>
<div className="max-w-2xl">
<p
className={`text-muted-foreground leading-7 ${
isCompact ? "text-sm" : "text-foreground/72 text-sm sm:text-[15px]"
} ${overviewTranslating ? "animate-pulse opacity-50" : ""}`}
>
{overview}
</p>
{overviewTranslating && (
<span className="text-muted-foreground/70 mt-1 inline-flex items-center gap-1.5 text-xs">
<Languages className="h-3 w-3 animate-pulse" />
Translating…
</span>
)}
{!overviewTranslating && onTranslateOverview && (
<button
type="button"
onClick={onTranslateOverview}
className="text-muted-foreground hover:text-foreground border-border/60 mt-1.5 inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs transition-colors"
>
<Languages className="h-3 w-3" />
Translate
</button>
)}
</div>
)}
{/* Crew line replaces genres when provided */}
@@ -33,6 +33,10 @@ const mocks = vi.hoisted(() => {
};
});
vi.mock("@/hooks/useOnViewTranslation", () => ({
useOnViewTranslation: () => ({ translating: false, onTranslate: undefined }),
}));
vi.mock("@/hooks/queries/favorites", () => ({
useIsFavorite: mocks.useIsFavorite,
useToggleFavorite: mocks.useToggleFavorite,
@@ -19,6 +19,7 @@ import MatchItemDialog from "@/components/MatchItemDialog";
import PageBack from "@/components/PageBack";
import RecommendationGrid from "@/components/RecommendationGrid";
import DetailHero from "./DetailHero";
import { useOnViewTranslation } from "@/hooks/useOnViewTranslation";
import MetadataBadges from "./components/MetadataBadges";
import QualityBadges from "./components/QualityBadges";
import ScoreRow from "./components/ScoreRow";
@@ -43,6 +44,8 @@ function formatDuration(minutes: number): string {
}
export default function MovieContent({ item }: { item: ItemDetail & { type: "movie" } }) {
const { translating: overviewTranslating, onTranslate: onTranslateOverview } =
useOnViewTranslation(item);
const navigate = useNavigate();
useAmbientColor(item.backdrop_thumbhash);
const { user } = useAuth();
@@ -213,6 +216,8 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov
/>
}
overview={item.overview}
overviewTranslating={overviewTranslating}
onTranslateOverview={onTranslateOverview}
crewLine={<HeroCrewLine crew={item.crew ?? []} genres={item.genres} />}
actions={
<ActionBar
@@ -36,6 +36,10 @@ const mocks = vi.hoisted(() => {
};
});
vi.mock("@/hooks/useOnViewTranslation", () => ({
useOnViewTranslation: () => ({ translating: false, onTranslate: undefined }),
}));
vi.mock("@/hooks/useAuth", () => ({
useAuth: mocks.useAuth,
useOptionalAuth: mocks.useAuth,
@@ -17,6 +17,7 @@ import MatchItemDialog from "@/components/MatchItemDialog";
import PageBack from "@/components/PageBack";
import RecommendationGrid from "@/components/RecommendationGrid";
import DetailHero from "./DetailHero";
import { useOnViewTranslation } from "@/hooks/useOnViewTranslation";
import SeasonCarousel from "./SeasonCarousel";
import SeasonEpisodeGrid from "./components/SeasonEpisodeGrid";
import MetadataBadges from "./components/MetadataBadges";
@@ -29,6 +30,8 @@ import { getWatchedActionLabel } from "./watchedState";
import { canCurateMetadata as canCurateMetadataForUser } from "@/lib/permissions";
export default function SeriesContent({ item }: { item: ItemDetail & { type: "series" } }) {
const { translating: overviewTranslating, onTranslate: onTranslateOverview } =
useOnViewTranslation(item);
const navigate = useNavigate();
useAmbientColor(item.backdrop_thumbhash);
const { user } = useAuth();
@@ -144,6 +147,8 @@ export default function SeriesContent({ item }: { item: ItemDetail & { type: "se
/>
}
overview={item.overview}
overviewTranslating={overviewTranslating}
onTranslateOverview={onTranslateOverview}
crewLine={
<HeroCrewLine crew={item.crew ?? []} genres={item.genres} jobLabel="Created by" />
}
@@ -0,0 +1,408 @@
import { useState, useEffect } from "react";
import { toast } from "sonner";
import {
useAdminSensitiveStatus,
useAdminServerSettings,
useUpdateServerSetting,
} from "@/hooks/queries/admin/settings";
import { Button } from "@/components/ui/button";
import { CredentialStatus } from "./CredentialStatus";
import { SettingField } from "./SettingField";
// Connection settings live under the ai.* keys; reads fall back to the legacy
// subtitle_ai.* rows (mirroring the server's loader) so an existing setup
// shows its effective values, while saves always write the new keys.
// Chat-only gateways have no timestamped transcription API; the server
// rejects them for the transcription URL — mirror that check for instant
// feedback. Keep in sync with llm.IsChatOnlyGateway.
const CHAT_ONLY_GATEWAY_HOSTS = ["openrouter.ai"];
function isChatOnlyGateway(rawUrl: string): boolean {
const trimmed = rawUrl.trim();
if (!trimmed) return false;
try {
const host = new URL(
trimmed.includes("://") ? trimmed : `https://${trimmed}`,
).hostname.toLowerCase();
return CHAT_ONLY_GATEWAY_HOSTS.some((g) => host === g || host.endsWith(`.${g}`));
} catch {
return false;
}
}
// Recommended transcription endpoints, fastest path first. Clicking a preset
// fills the URL + model; the API key still comes from the operator.
const TRANSCRIPTION_PRESETS: {
id: string;
label: string;
description: string;
baseUrl: string;
model: string;
}[] = [
{
id: "local",
label: "Self-hosted · recommended",
description:
"A speaches/faster-whisper server on your own hardware — private, free, and no rate limits. Adjust the URL to where it runs; no API key needed.",
baseUrl: "http://localhost:8000",
model: "deepdml/faster-whisper-large-v3-turbo-ct2",
},
{
id: "groq-turbo",
label: "Groq · hosted fallback",
description:
"whisper-large-v3-turbo on Groq — fastest hosted option, very low cost (free tier covers ~2 audio-hours per hour). Needs a Groq API key in the transcription key field.",
baseUrl: "https://api.groq.com/openai",
model: "whisper-large-v3-turbo",
},
{
id: "groq-accurate",
label: "Groq · most accurate",
description:
"whisper-large-v3 on Groq — best multilingual accuracy among hosted options, slightly slower and pricier than turbo.",
baseUrl: "https://api.groq.com/openai",
model: "whisper-large-v3",
},
{
id: "openai",
label: "OpenAI",
description:
"whisper-1 on OpenAI — solid quality, higher cost than Groq. Uses the main API key if the transcription key is blank.",
baseUrl: "https://api.openai.com",
model: "whisper-1",
},
];
function AIConnectionCard() {
const { data: settings } = useAdminServerSettings();
const { data: sensitive } = useAdminSensitiveStatus();
const updateSetting = useUpdateServerSetting();
const configuredKeys = new Set(sensitive?.configured ?? []);
const apiKeyConfigured =
configuredKeys.has("ai.api_key") || configuredKeys.has("subtitle_ai.api_key");
const asrApiKeyConfigured = configuredKeys.has("ai.asr_api_key");
const [baseUrl, setBaseUrl] = useState("");
const [chatModel, setChatModel] = useState("");
const [apiKey, setApiKey] = useState("");
const [asrModel, setAsrModel] = useState("");
const [asrBaseUrl, setAsrBaseUrl] = useState("");
const [asrApiKey, setAsrApiKey] = useState("");
const [maxConcurrent, setMaxConcurrent] = useState("2");
useEffect(() => {
if (!settings) return;
setBaseUrl(
settings["ai.base_url"] ?? settings["subtitle_ai.base_url"] ?? "https://api.openai.com",
);
setChatModel(settings["ai.chat_model"] ?? settings["subtitle_ai.chat_model"] ?? "gpt-4o-mini");
setAsrModel(settings["ai.asr_model"] ?? "whisper-1");
setAsrBaseUrl(settings["ai.asr_base_url"] ?? "");
setMaxConcurrent(
settings["ai.max_concurrent_jobs"] ?? settings["subtitle_ai.max_concurrent_jobs"] ?? "2",
);
}, [settings]);
function save() {
const trimmedBaseUrl = baseUrl.trim();
const trimmedChatModel = chatModel.trim();
const parsedMaxConcurrent = Number.parseInt(maxConcurrent, 10);
if (trimmedBaseUrl === "" || trimmedChatModel === "") {
toast.error("Base URL and chat model are required.");
return;
}
if (!Number.isInteger(parsedMaxConcurrent) || parsedMaxConcurrent < 1) {
toast.error("Max concurrent jobs must be a positive whole number.");
return;
}
if (isChatOnlyGateway(asrBaseUrl)) {
toast.error(
"That endpoint can't produce timestamped transcriptions (chat-only gateway). Pick a transcription preset below or use a Whisper-capable server.",
);
return;
}
const updates = [
updateSetting.mutateAsync({ key: "ai.base_url", value: trimmedBaseUrl }),
updateSetting.mutateAsync({ key: "ai.chat_model", value: trimmedChatModel }),
updateSetting.mutateAsync({ key: "ai.asr_model", value: asrModel.trim() }),
updateSetting.mutateAsync({ key: "ai.asr_base_url", value: asrBaseUrl.trim() }),
updateSetting.mutateAsync({
key: "ai.max_concurrent_jobs",
value: String(parsedMaxConcurrent),
}),
];
if (apiKey.trim() !== "") {
updates.push(updateSetting.mutateAsync({ key: "ai.api_key", value: apiKey }));
}
if (asrApiKey.trim() !== "") {
updates.push(updateSetting.mutateAsync({ key: "ai.asr_api_key", value: asrApiKey }));
}
void Promise.all(updates).then(() => {
setApiKey("");
setAsrApiKey("");
});
}
return (
<div className="border-border bg-surface max-w-2xl rounded-lg border px-5 py-4">
<div className="mb-2 flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold">Endpoint</h3>
<p className="text-muted-foreground text-xs">
One OpenAI-compatible endpoint shared by every AI feature (OpenAI, Groq, a local Ollama
server, …). Transcription can use a separate Whisper-compatible server.
</p>
</div>
<CredentialStatus configured={apiKeyConfigured} />
</div>
<SettingField
label="Base URL"
type="text"
value={baseUrl}
onChange={setBaseUrl}
hint="https://api.openai.com"
/>
<SettingField
label="Chat model"
type="text"
value={chatModel}
onChange={setChatModel}
hint="Used for subtitle and description translation, e.g. gpt-4o-mini, llama3.1"
/>
<SettingField
label="API Key"
type="password"
value={apiKey}
onChange={setApiKey}
sensitiveConfigured={apiKeyConfigured}
hint="Leave blank to keep current. Empty is fine for keyless local servers."
/>
<div className="border-border/60 mt-4 mb-1 border-t pt-3">
<p className="text-sm font-medium">Transcription</p>
<p className="text-muted-foreground text-xs">
Subtitle generation needs a Whisper endpoint that returns segment timestamps. Pick a
preset or configure your own:
</p>
<div className="mt-2 flex flex-wrap gap-2">
{TRANSCRIPTION_PRESETS.map((preset) => {
const active = asrBaseUrl.trim() === preset.baseUrl && asrModel.trim() === preset.model;
return (
<button
key={preset.id}
type="button"
title={preset.description}
onClick={() => {
setAsrBaseUrl(preset.baseUrl);
setAsrModel(preset.model);
}}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
active
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-foreground"
}`}
>
{preset.label}
</button>
);
})}
</div>
</div>
<SettingField
label="Transcription model"
type="text"
value={asrModel}
onChange={setAsrModel}
hint="Whisper model for subtitle generation, e.g. whisper-large-v3-turbo"
/>
<SettingField
label="Transcription base URL"
type="text"
value={asrBaseUrl}
onChange={setAsrBaseUrl}
hint="Whisper-capable endpoint with segment timestamps: a self-hosted faster-whisper/speaches server (recommended), api.groq.com/openai, or api.openai.com. Blank uses the base URL — note that chat-only gateways (e.g. OpenRouter) cannot transcribe."
/>
<SettingField
label="Transcription API key"
type="password"
value={asrApiKey}
onChange={setAsrApiKey}
sensitiveConfigured={asrApiKeyConfigured}
hint="Optional; blank uses the main API key."
/>
<SettingField
label="Max concurrent jobs"
type="number"
value={maxConcurrent}
onChange={setMaxConcurrent}
hint="One shared cap across subtitle translation, transcription, and description translation."
/>
<div className="pt-2">
<Button type="button" onClick={save} disabled={updateSetting.isPending}>
{updateSetting.isPending ? "Saving..." : "Save Endpoint Settings"}
</Button>
<p className="text-muted-foreground mt-2 text-xs">
Changes take effect after a server restart.
</p>
</div>
</div>
);
}
function AIFeaturesCard() {
const { data: settings } = useAdminServerSettings();
const updateSetting = useUpdateServerSetting();
const [subtitleTranslate, setSubtitleTranslate] = useState("false");
const [transcribe, setTranscribe] = useState("false");
const [metadataTranslate, setMetadataTranslate] = useState("false");
const [onView, setOnView] = useState("off");
const [batchSize, setBatchSize] = useState("40");
const [contextNeighbors, setContextNeighbors] = useState("2");
const [asrChunkSeconds, setAsrChunkSeconds] = useState("600");
useEffect(() => {
if (!settings) return;
setSubtitleTranslate(settings["subtitle_ai.enabled"] ?? "false");
setTranscribe(settings["subtitle_ai.transcribe_enabled"] ?? "false");
setMetadataTranslate(settings["metadata_ai.enabled"] ?? "false");
setOnView(settings["metadata_ai.on_view"] ?? "off");
setBatchSize(settings["subtitle_ai.batch_size"] ?? "40");
setContextNeighbors(settings["subtitle_ai.context_neighbors"] ?? "2");
setAsrChunkSeconds(settings["subtitle_ai.asr_chunk_seconds"] ?? "600");
}, [settings]);
function save() {
const parsedBatch = Number.parseInt(batchSize, 10);
const parsedNeighbors = Number.parseInt(contextNeighbors, 10);
if (!Number.isInteger(parsedBatch) || parsedBatch < 1) {
toast.error("Batch size must be a positive whole number.");
return;
}
if (!Number.isInteger(parsedNeighbors) || parsedNeighbors < 0) {
toast.error("Context lines must be zero or a positive whole number.");
return;
}
const parsedChunkSeconds = Number.parseInt(asrChunkSeconds, 10);
if (
!Number.isInteger(parsedChunkSeconds) ||
parsedChunkSeconds < 60 ||
parsedChunkSeconds > 600
) {
toast.error("Transcription chunk length must be between 60 and 600 seconds.");
return;
}
void Promise.all([
updateSetting.mutateAsync({ key: "subtitle_ai.enabled", value: subtitleTranslate }),
updateSetting.mutateAsync({ key: "subtitle_ai.transcribe_enabled", value: transcribe }),
updateSetting.mutateAsync({ key: "metadata_ai.enabled", value: metadataTranslate }),
updateSetting.mutateAsync({ key: "metadata_ai.on_view", value: onView }),
updateSetting.mutateAsync({ key: "subtitle_ai.batch_size", value: String(parsedBatch) }),
updateSetting.mutateAsync({
key: "subtitle_ai.context_neighbors",
value: String(parsedNeighbors),
}),
updateSetting.mutateAsync({
key: "subtitle_ai.asr_chunk_seconds",
value: String(parsedChunkSeconds),
}),
]);
}
return (
<div className="border-border bg-surface max-w-2xl rounded-lg border px-5 py-4">
<div className="mb-2">
<h3 className="text-sm font-semibold">Features</h3>
<p className="text-muted-foreground text-xs">
Everything runs once on the server and is served to every client through the normal
subtitle and metadata pipelines.
</p>
</div>
<SettingField
label="Subtitle translation"
type="toggle"
value={subtitleTranslate}
onChange={setSubtitleTranslate}
hint="Show the “Translate with AI” action in the player."
/>
<SettingField
label="Subtitle generation from audio"
type="toggle"
value={transcribe}
onChange={setTranscribe}
hint="Whisper transcription — generates subtitle tracks for media with no usable text subtitles."
/>
<SettingField
label="Description translation"
type="toggle"
value={metadataTranslate}
onChange={setMetadataTranslate}
hint="Translate overviews and taglines from the metadata editor, plus the per-library auto-translate option."
/>
<SettingField
label="On-view translation"
type="select"
value={onView}
onChange={setOnView}
options={[
{ value: "off", label: "Off" },
{ value: "button", label: "Translate button on detail pages" },
{ value: "auto", label: "Automatic on view" },
]}
hint="Let viewers get descriptions in their profile's metadata language: a Translate button, or automatic translation when they open a detail page. Requires description translation."
/>
<SettingField
label="Subtitle batch size"
type="number"
value={batchSize}
onChange={setBatchSize}
hint="Cues per translation request."
/>
<SettingField
label="Subtitle context lines"
type="number"
value={contextNeighbors}
onChange={setContextNeighbors}
hint="Preceding source cues sent for scene continuity across batches."
/>
<SettingField
label="Transcription chunk length (seconds)"
type="number"
value={asrChunkSeconds}
onChange={setAsrChunkSeconds}
hint="60–600. Shorter chunks keep Whisper timestamps tighter on long files (try 300 if subtitles drift), at the cost of more requests and occasional clipped words at chunk boundaries."
/>
<div className="pt-2">
<Button type="button" onClick={save} disabled={updateSetting.isPending}>
{updateSetting.isPending ? "Saving..." : "Save Feature Settings"}
</Button>
<p className="text-muted-foreground mt-2 text-xs">
Changes take effect after a server restart.
</p>
</div>
</div>
);
}
export default function AIServicesSettings() {
return (
<div className="flex h-full flex-col">
<div className="mb-6 space-y-2">
<h2 className="text-xl font-semibold tracking-tight">AI Services</h2>
<p className="text-muted-foreground text-sm leading-relaxed">
Shared AI endpoint and feature toggles for subtitle translation, subtitle generation from
audio, and description translation.
</p>
</div>
<div className="space-y-8">
<AIConnectionCard />
<AIFeaturesCard />
</div>
</div>
);
}
@@ -15,6 +15,7 @@ import {
Paintbrush,
Layers,
Subtitles,
Sparkles,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
@@ -26,6 +27,7 @@ import PlaybackSettings from "./PlaybackSettings";
import ScannerSettings from "./ScannerSettings";
import IntroSettings from "./IntroSettings";
import SubtitlesSettings from "./SubtitlesSettings";
import AIServicesSettings from "./AIServicesSettings";
import RateLimitSettings from "./RateLimitSettings";
import WatchProvidersSettings from "./WatchProvidersSettings";
import IntegrationsSettings from "./IntegrationsSettings";
@@ -66,6 +68,7 @@ const SETTINGS_GROUPS: SettingsNavGroup[] = [
{ id: "scanner", label: "Scanner & Matcher", icon: ScanSearch, component: ScannerSettings },
{ id: "intro", label: "Intro Markers", icon: Captions, component: IntroSettings },
{ id: "subtitles", label: "Subtitles", icon: Subtitles, component: SubtitlesSettings },
{ id: "ai", label: "AI Services", icon: Sparkles, component: AIServicesSettings },
{ id: "playback", label: "Playback", icon: PlayCircle, component: PlaybackSettings },
{ id: "downloads", label: "Downloads", icon: Download, component: DownloadSettings },
],
@@ -1,15 +1,9 @@
import { useState, useEffect } from "react";
import { toast } from "sonner";
import {
useSubtitleProviders,
useUpdateSubtitleProvider,
useTestSubtitleProvider,
} from "@/hooks/queries/admin/subtitles";
import {
useAdminSensitiveStatus,
useAdminServerSettings,
useUpdateServerSetting,
} from "@/hooks/queries/admin/settings";
import type { SubtitleProviderConfig } from "@/api/types";
import { Button } from "@/components/ui/button";
@@ -19,7 +13,6 @@ import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Eye, EyeOff } from "lucide-react";
import { CredentialStatus } from "./CredentialStatus";
import { SettingField } from "./SettingField";
// ============================================================================
// Search providers
@@ -252,138 +245,19 @@ function SearchProvidersContent() {
);
}
// ============================================================================
// AI translation
// ============================================================================
function AISubtitleTranslationCard() {
const { data: settings } = useAdminServerSettings();
const { data: sensitive } = useAdminSensitiveStatus();
const updateSetting = useUpdateServerSetting();
const apiKeyConfigured = new Set(sensitive?.configured ?? []).has("subtitle_ai.api_key");
const [enabled, setEnabled] = useState("false");
const [baseUrl, setBaseUrl] = useState("");
const [chatModel, setChatModel] = useState("");
const [maxConcurrent, setMaxConcurrent] = useState("2");
const [apiKey, setApiKey] = useState("");
// Hydrate the form from current server settings once loaded.
useEffect(() => {
if (!settings) return;
setEnabled(settings["subtitle_ai.enabled"] ?? "false");
setBaseUrl(settings["subtitle_ai.base_url"] ?? "https://api.openai.com");
setChatModel(settings["subtitle_ai.chat_model"] ?? "gpt-4o-mini");
setMaxConcurrent(settings["subtitle_ai.max_concurrent_jobs"] ?? "2");
}, [settings]);
function save() {
const trimmedBaseUrl = baseUrl.trim();
const trimmedChatModel = chatModel.trim();
const parsedMaxConcurrent = Number.parseInt(maxConcurrent, 10);
// Don't let an admin persist a config that would break translation for
// everyone (a blank endpoint/model when enabled, or a bad concurrency value).
if (enabled === "true" && (trimmedBaseUrl === "" || trimmedChatModel === "")) {
toast.error("Base URL and chat model are required to enable AI translation.");
return;
}
if (!Number.isInteger(parsedMaxConcurrent) || parsedMaxConcurrent < 1) {
toast.error("Max concurrent jobs must be a positive whole number.");
return;
}
const updates = [
updateSetting.mutateAsync({ key: "subtitle_ai.enabled", value: enabled }),
updateSetting.mutateAsync({ key: "subtitle_ai.base_url", value: trimmedBaseUrl }),
updateSetting.mutateAsync({ key: "subtitle_ai.chat_model", value: trimmedChatModel }),
updateSetting.mutateAsync({
key: "subtitle_ai.max_concurrent_jobs",
value: String(parsedMaxConcurrent),
}),
];
if (apiKey.trim() !== "") {
updates.push(updateSetting.mutateAsync({ key: "subtitle_ai.api_key", value: apiKey }));
}
void Promise.all(updates).then(() => setApiKey(""));
}
return (
<div className="border-border bg-surface max-w-2xl rounded-lg border px-5 py-4">
<div className="mb-2 flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold">AI Subtitle Translation</h3>
<p className="text-muted-foreground text-xs">
On-demand subtitle translation via any OpenAI-compatible chat API (OpenAI, Groq, a local
Ollama server, …). Translated tracks are generated once on the server and served to
every client.
</p>
</div>
<CredentialStatus configured={apiKeyConfigured} />
</div>
<SettingField
label="Enabled"
type="toggle"
value={enabled}
onChange={setEnabled}
hint="Show the “Translate with AI” action in the player."
/>
<SettingField
label="Base URL"
type="text"
value={baseUrl}
onChange={setBaseUrl}
hint="https://api.openai.com"
/>
<SettingField
label="Chat model"
type="text"
value={chatModel}
onChange={setChatModel}
hint="e.g. gpt-4o-mini, llama3.1"
/>
<SettingField
label="API Key"
type="password"
value={apiKey}
onChange={setApiKey}
sensitiveConfigured={apiKeyConfigured}
hint="Leave blank to keep current. Empty is fine for keyless local servers."
/>
<SettingField
label="Max concurrent jobs"
type="number"
value={maxConcurrent}
onChange={setMaxConcurrent}
hint="Caps simultaneous translations so they don't starve transcodes."
/>
<div className="pt-2">
<Button type="button" onClick={save} disabled={updateSetting.isPending}>
{updateSetting.isPending ? "Saving..." : "Save AI Translation Settings"}
</Button>
<p className="text-muted-foreground mt-2 text-xs">
Changes take effect after a server restart.
</p>
</div>
</div>
);
}
export default function SubtitlesSettings() {
return (
<div className="flex h-full flex-col">
<div className="mb-6 space-y-2">
<h2 className="text-xl font-semibold tracking-tight">Subtitles</h2>
<p className="text-muted-foreground text-sm leading-relaxed">
Search providers for downloading subtitles and AI translation for generating new language
tracks.
Search providers for downloading subtitles. AI translation and transcription live under AI
Services.
</p>
</div>
<div className="space-y-8">
<SearchProvidersContent />
<AISubtitleTranslationCard />
</div>
</div>
);
@@ -114,6 +114,7 @@ export default function PlaybackSettings() {
const saveProfileField = (body: {
quality_preference?: string;
language?: string;
preferred_metadata_language?: string;
auto_skip_intro?: boolean;
auto_skip_credits?: boolean;
auto_skip_recap?: boolean;
@@ -203,6 +204,39 @@ export default function PlaybackSettings() {
)}
/>
<SettingRow
label="Metadata language"
description="Show titles and descriptions in this language when available. Descriptions can be translated automatically when the server has AI translation enabled."
control={(id) => (
<div className="w-full">
<Select
value={profile.preferred_metadata_language || "none"}
onValueChange={(value) =>
saveProfileField({
preferred_metadata_language: value === "none" ? "" : value,
})
}
>
<SelectTrigger
id={id}
className="w-full sm:w-[220px]"
disabled={updateMutation.isPending}
>
<SelectValue placeholder="Library default" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Library default</SelectItem>
{LANGUAGE_OPTIONS.filter((language) => language.value).map((language) => (
<SelectItem key={language.value} value={language.value}>
{language.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
/>
<SettingRow
label="Auto-skip intros"
description="Jump past intros automatically when Silo can detect them."
@@ -332,6 +332,7 @@ export function PlayerControls({
onRefreshSubtitles={onRefreshSubtitles}
sessionId={sessionId}
getSubtitleStartPosition={getSubtitleStartPosition}
audioTracks={audioTracks}
/>
<QualityMenu
+37 -21
View File
@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import { createPortal } from "react-dom";
import { Captions, CaptionsOff, Languages, Minus, Plus, SlidersHorizontal } from "lucide-react";
import type { PlayerSubtitleInfo } from "../types";
import type { PlayerAudioTrack, PlayerSubtitleInfo } from "../types";
import type { PlayerConfig } from "../context/PlayerConfigContext";
import { SubtitleSearchModal } from "./SubtitleSearchModal";
import { SubtitleTranslateModal, isTranslatableSource } from "./SubtitleTranslateModal";
@@ -22,6 +22,7 @@ interface SubtitleMenuProps {
onRefreshSubtitles?: () => void;
sessionId?: string;
getSubtitleStartPosition?: () => number;
audioTracks?: PlayerAudioTrack[];
}
const DELAY_STEP_MS = 100;
@@ -50,11 +51,13 @@ export function SubtitleMenu({
onRefreshSubtitles,
sessionId,
getSubtitleStartPosition,
audioTracks,
}: SubtitleMenuProps) {
const [open, setOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const [translateOpen, setTranslateOpen] = useState(false);
const [aiEnabled, setAiEnabled] = useState(false);
const [aiTranscribeEnabled, setAiTranscribeEnabled] = useState(false);
const [appearanceOpen, setAppearanceOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@@ -67,12 +70,19 @@ export function SubtitleMenu({
useEffect(() => {
if (!playerConfig) return;
let cancelled = false;
playerFetch<{ enabled: boolean }>(playerConfig, "/subtitles/ai/status")
playerFetch<{ enabled: boolean; transcribe_enabled?: boolean }>(
playerConfig,
"/subtitles/ai/status",
)
.then((res) => {
if (!cancelled) setAiEnabled(Boolean(res?.enabled));
if (cancelled) return;
setAiEnabled(Boolean(res?.enabled));
setAiTranscribeEnabled(Boolean(res?.transcribe_enabled));
})
.catch(() => {
if (!cancelled) setAiEnabled(false);
if (cancelled) return;
setAiEnabled(false);
setAiTranscribeEnabled(false);
});
return () => {
cancelled = true;
@@ -301,23 +311,26 @@ export function SubtitleMenu({
Search Online…
</button>
)}
{aiEnabled && mediaFileId && playerConfig && tracks.some(isTranslatableSource) && (
<button
ref={(el) => {
menuItemsRef.current[menuItemIndex + 2] = el;
}}
role="menuitem"
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-white/70 hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none"
onClick={() => {
setTranslateOpen(true);
setOpen(false);
}}
>
<Languages className="h-3.5 w-3.5 text-white/50" />
Translate with AI…
</button>
)}
{mediaFileId &&
playerConfig &&
((aiEnabled && tracks.some(isTranslatableSource)) ||
(aiTranscribeEnabled && (audioTracks?.length ?? 0) > 0)) && (
<button
ref={(el) => {
menuItemsRef.current[menuItemIndex + 2] = el;
}}
role="menuitem"
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-white/70 hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none"
onClick={() => {
setTranslateOpen(true);
setOpen(false);
}}
>
<Languages className="h-3.5 w-3.5 text-white/50" />
Translate with AI…
</button>
)}
<button
ref={(el) => {
menuItemsRef.current[menuItemIndex + 3] = el;
@@ -361,6 +374,9 @@ export function SubtitleMenu({
mediaFileId={mediaFileId}
playerConfig={playerConfig}
tracks={tracks}
audioTracks={audioTracks}
translateEnabled={aiEnabled}
transcribeEnabled={aiTranscribeEnabled}
isOpen={translateOpen}
sessionId={sessionId}
getStartPosition={getSubtitleStartPosition}
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import type { PlayerConfig } from "../context/PlayerConfigContext";
import type { PlayerSubtitleInfo } from "../types";
import type { PlayerAudioTrack, PlayerSubtitleInfo } from "../types";
import { playerFetch } from "../player-fetch";
import { LANGUAGES, getLanguageName } from "../utils/languageNames";
@@ -28,6 +28,9 @@ interface SubtitleTranslateModalProps {
mediaFileId: number;
playerConfig: PlayerConfig;
tracks: PlayerSubtitleInfo[];
audioTracks?: PlayerAudioTrack[];
translateEnabled?: boolean;
transcribeEnabled?: boolean;
isOpen: boolean;
sessionId?: string;
getStartPosition?: () => number;
@@ -40,10 +43,19 @@ function sourceLabel(track: PlayerSubtitleInfo): string {
return `${lang}${origin}`;
}
function audioLabel(track: PlayerAudioTrack, i: number): string {
const lang = getLanguageName(track.language ?? "") || track.language || `Track ${i + 1}`;
const layout = track.layout ? ` · ${track.layout}` : "";
return `${lang}${layout}${track.default ? " · default" : ""}`;
}
export function SubtitleTranslateModal({
mediaFileId,
playerConfig,
tracks,
audioTracks,
translateEnabled = true,
transcribeEnabled = false,
isOpen,
sessionId,
getStartPosition,
@@ -52,7 +64,13 @@ export function SubtitleTranslateModal({
// Only offer sources the server can actually translate (excludes live tracks,
// bitmap embedded tracks, and ASS/non-text external/downloaded tracks).
const sourceTracks = useMemo(() => tracks.filter(isTranslatableSource), [tracks]);
const canTranslate = translateEnabled && sourceTracks.length > 0;
const canTranscribe = transcribeEnabled && (audioTracks?.length ?? 0) > 0;
// Subtitle translation is the default; generating from audio takes over when
// it's the only possible path (e.g. bitmap-only files).
const [mode, setMode] = useState<"subtitles" | "audio">(canTranslate ? "subtitles" : "audio");
const [sourceIndex, setSourceIndex] = useState<number | null>(null);
const [audioIndex, setAudioIndex] = useState(0);
const [targetLang, setTargetLang] = useState("en");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -69,45 +87,68 @@ export function SubtitleTranslateModal({
}, [isOpen, onClose]);
const handleTranslate = useCallback(async () => {
if (effectiveSourceIndex === null) return;
const source = sourceTracks.find((t) => t.index === effectiveSourceIndex);
const fromAudio = mode === "audio";
if (!fromAudio && effectiveSourceIndex === null) return;
setSubmitting(true);
setError(null);
try {
let body: Record<string, unknown>;
if (fromAudio) {
const audio = audioTracks?.[audioIndex];
// Same target as the audio language -> plain transcription; otherwise
// transcribe then translate.
const sameLanguage = (audio?.language ?? "") === targetLang;
body = {
media_file_id: mediaFileId,
kind: sameLanguage ? "transcribe" : "transcribe_translate",
source_index: audioIndex,
source_language: audio?.language ?? "",
target_language: sameLanguage ? "" : targetLang,
session_id: sessionId ?? "",
start_position: getStartPosition?.() ?? 0,
};
} else {
const source = sourceTracks.find((t) => t.index === effectiveSourceIndex);
body = {
media_file_id: mediaFileId,
source_index: effectiveSourceIndex,
source_language: source?.language ?? "",
target_language: targetLang,
session_id: sessionId ?? "",
start_position: getStartPosition?.() ?? 0,
};
}
const res = await playerFetch<{ job?: { status?: string } }>(
playerConfig,
"/subtitles/ai/translate",
{
method: "POST",
body: JSON.stringify({
media_file_id: mediaFileId,
source_index: effectiveSourceIndex,
source_language: source?.language ?? "",
target_language: targetLang,
session_id: sessionId ?? "",
start_position: getStartPosition?.() ?? 0,
}),
},
{ method: "POST", body: JSON.stringify(body) },
);
// A request that collapses onto an already-running job (e.g. after a
// reload, or a second viewer) won't get its own live stream — tell the
// user it's underway; it'll appear via the subtitle-ready refresh.
if (res?.job?.status === "running") {
toast.info(
"A translation for this track is already in progress — it'll appear when it's ready.",
);
toast.info("A job for this track is already in progress — it'll appear when it's ready.");
}
// Otherwise the player takes over: it pauses, streams cues in as they're
// translated, then resumes once your position is covered.
// generated, then resumes once your position is covered.
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Couldn't start translation.");
setError(
err instanceof Error
? err.message
: mode === "audio"
? "Couldn't start subtitle generation."
: "Couldn't start translation.",
);
} finally {
setSubmitting(false);
}
}, [
mode,
effectiveSourceIndex,
sourceTracks,
audioTracks,
audioIndex,
mediaFileId,
targetLang,
sessionId,
@@ -131,7 +172,9 @@ export function SubtitleTranslateModal({
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
<h2 className="text-sm font-semibold">Translate subtitles with AI</h2>
<h2 className="text-sm font-semibold">
{mode === "audio" ? "Generate subtitles with AI" : "Translate subtitles with AI"}
</h2>
<button
type="button"
className="rounded text-white/60 hover:text-white focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none"
@@ -143,30 +186,77 @@ export function SubtitleTranslateModal({
</div>
<div className="space-y-3 px-4 py-4">
{sourceTracks.length === 0 ? (
{canTranslate && canTranscribe && (
<div className="flex gap-1 rounded bg-neutral-800 p-1" role="tablist">
{(
[
["subtitles", "From subtitles"],
["audio", "From audio"],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
role="tab"
aria-selected={mode === value}
className={`flex-1 rounded px-2 py-1 text-xs font-medium focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none ${
mode === value ? "bg-white/15 text-white" : "text-white/50 hover:text-white/80"
}`}
onClick={() => setMode(value)}
disabled={submitting}
>
{label}
</button>
))}
</div>
)}
{!canTranslate && !canTranscribe ? (
<p className="py-4 text-center text-xs text-white/50">
No text subtitle track is available to translate. Add or download one first.
</p>
) : (
<>
<label className="block">
<span className="mb-1 block text-xs font-medium text-white/60">Translate from</span>
<select
className="w-full rounded bg-neutral-800 px-2 py-1.5 text-sm text-white focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none disabled:opacity-50"
value={effectiveSourceIndex ?? ""}
onChange={(e) => setSourceIndex(Number(e.target.value))}
disabled={submitting}
>
{sourceTracks.map((track) => (
<option key={track.index} value={track.index}>
{sourceLabel(track)}
</option>
))}
</select>
</label>
{mode === "subtitles" ? (
<label className="block">
<span className="mb-1 block text-xs font-medium text-white/60">
Translate from
</span>
<select
className="w-full rounded bg-neutral-800 px-2 py-1.5 text-sm text-white focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none disabled:opacity-50"
value={effectiveSourceIndex ?? ""}
onChange={(e) => setSourceIndex(Number(e.target.value))}
disabled={submitting}
>
{sourceTracks.map((track) => (
<option key={track.index} value={track.index}>
{sourceLabel(track)}
</option>
))}
</select>
</label>
) : (
<label className="block">
<span className="mb-1 block text-xs font-medium text-white/60">Audio track</span>
<select
className="w-full rounded bg-neutral-800 px-2 py-1.5 text-sm text-white focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none disabled:opacity-50"
value={audioIndex}
onChange={(e) => setAudioIndex(Number(e.target.value))}
disabled={submitting}
>
{(audioTracks ?? []).map((track, i) => (
<option key={i} value={i}>
{audioLabel(track, i)}
</option>
))}
</select>
</label>
)}
<label className="block">
<span className="mb-1 block text-xs font-medium text-white/60">Translate to</span>
<span className="mb-1 block text-xs font-medium text-white/60">
{mode === "audio" ? "Subtitle language" : "Translate to"}
</span>
<select
className="w-full rounded bg-neutral-800 px-2 py-1.5 text-sm text-white focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none disabled:opacity-50"
value={targetLang}
@@ -199,15 +289,16 @@ export function SubtitleTranslateModal({
type="button"
className="rounded bg-white/10 px-3 py-1.5 text-sm font-medium hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none disabled:opacity-50"
onClick={handleTranslate}
disabled={submitting || effectiveSourceIndex === null}
disabled={submitting || (mode === "subtitles" && effectiveSourceIndex === null)}
>
{submitting ? "Starting…" : "Translate"}
{submitting ? "Starting…" : mode === "audio" ? "Generate" : "Translate"}
</button>
</div>
<p className="text-[11px] leading-relaxed text-white/35">
Playback pauses while the first lines are translated, then resumes with subtitles
streaming in. The finished track is saved for everyone.
{mode === "audio"
? "The audio is transcribed on the server (and translated if the language differs) — longer files take a while. The finished track is saved for everyone."
: "Playback pauses while the first lines are translated, then resumes with subtitles streaming in. The finished track is saved for everyone."}
</p>
</>
)}