- One OpenAI-compatible endpoint shared by every AI feature (OpenAI, Groq, a local Ollama
- server, …). Transcription can use a separate Whisper-compatible server.
+
+
+
+
+
+
+
{title}
+
+ {description}
-
-
-
-
-
-
Transcription
-
- Subtitle generation needs a Whisper endpoint that returns segment timestamps. Pick a
- preset or configure your own:
-
-
- {TRANSCRIPTION_PRESETS.map((preset) => {
- const active = asrBaseUrl.trim() === preset.baseUrl && asrModel.trim() === preset.model;
- return (
-
- );
- })}
-
-
-
-
-
-
-
-
-
- Endpoint, model, and credential changes apply live. Changing the concurrency cap requires
- a restart.
-
- {restartRequired && (
-
-
-
- Restart required to resize the AI job pool.
-
-
-
+
-
+ >
+ {statusTone === "ready" ? (
+
+ ) : statusTone === "warning" ? (
+
+ ) : null}
+ {status}
+
+
);
}
-function AIFeaturesCard() {
- const { data: settings } = useAdminServerSettings();
- const updateSettings = useUpdateServerSettings();
-
- 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");
- const [transcribeQuotaJobs, setTranscribeQuotaJobs] = useState("0");
- const [transcribeQuotaPeriod, setTranscribeQuotaPeriod] = useState("day");
-
- 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");
- setTranscribeQuotaJobs(settings["subtitle_ai.transcribe_quota_jobs"] ?? "0");
- setTranscribeQuotaPeriod(settings["subtitle_ai.transcribe_quota_period"] ?? "day");
- }, [settings]);
-
- async 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;
- }
- const parsedQuotaJobs = Number.parseInt(transcribeQuotaJobs, 10);
- if (!Number.isInteger(parsedQuotaJobs) || parsedQuotaJobs < 0) {
- toast.error("Transcription limit must be zero (unlimited) or a positive whole number.");
- return;
- }
- const candidates: Record
= {
- "subtitle_ai.enabled": subtitleTranslate,
- "subtitle_ai.transcribe_enabled": transcribe,
- "metadata_ai.enabled": metadataTranslate,
- "metadata_ai.on_view": onView,
- "subtitle_ai.batch_size": String(parsedBatch),
- "subtitle_ai.context_neighbors": String(parsedNeighbors),
- "subtitle_ai.asr_chunk_seconds": String(parsedChunkSeconds),
- "subtitle_ai.transcribe_quota_jobs": String(parsedQuotaJobs),
- "subtitle_ai.transcribe_quota_period": transcribeQuotaPeriod,
- };
- const updates = Object.fromEntries(
- Object.entries(candidates).filter(([key, value]) => settings?.[key] !== value),
- );
- if (Object.keys(updates).length === 0) {
- toast.info("No feature settings changed.");
- return;
- }
- try {
- await updateSettings.mutateAsync(updates);
- toast.success("AI feature settings saved");
- } catch {
- // The mutation reports the API error.
- }
- }
-
+function RequirementNote({
+ label,
+ ready,
+ detail,
+}: {
+ label: string;
+ ready: boolean;
+ detail: string;
+}) {
return (
-
+
+ {ready ? (
+
+ ) : (
+
+ )}
+
+ {label} - {detail}
+
+
);
}
export default function AIServicesSettings() {
+ const form = useSettingsForm({ keys: KEYS });
+ const textCheck = useCheckAdminSettingsConnection();
+ const speechCheck = useCheckAdminSettingsConnection();
+ const [textResult, setTextResult] = useState(null);
+ const [speechResult, setSpeechResult] = useState(null);
+
+ if (form.isLoading) {
+ return (
+
+
+
+
+
+ Loading AI settings
+
+ );
+ }
+
+ const value = (key: string, fallback = "") => form.getValue(key) || fallback;
+ const effectiveValue = (key: string, legacyKey: string, fallback: string) =>
+ value(key, value(legacyKey, fallback));
+ const textBaseURL = effectiveValue(
+ "ai.base_url",
+ "subtitle_ai.base_url",
+ "https://api.openai.com",
+ );
+ const chatModel = effectiveValue("ai.chat_model", "subtitle_ai.chat_model", "gpt-4o-mini");
+ const asrBaseURL = value("ai.asr_base_url");
+ const asrModel = value("ai.asr_model", "whisper-1");
+ const textReady = textBaseURL.trim() !== "" && chatModel.trim() !== "";
+ const speechUsesTextEndpoint = asrBaseURL.trim() === "";
+ const speechCheckable =
+ (asrBaseURL.trim() !== "" || textBaseURL.trim() !== "") && asrModel.trim() !== "";
+ const speechCompatible = !isChatOnlyGateway(speechUsesTextEndpoint ? textBaseURL : asrBaseURL);
+ const speechReady = speechCheckable && speechCompatible;
+ const descriptionEnabled = value("metadata_ai.enabled", "false") === "true";
+
+ function setValue(key: string, nextValue: string) {
+ form.setValue(key, nextValue);
+ if (TEXT_AI_KEYS.includes(key as (typeof TEXT_AI_KEYS)[number])) {
+ setTextResult(null);
+ }
+ if (SPEECH_AI_KEYS.includes(key as (typeof SPEECH_AI_KEYS)[number])) {
+ setSpeechResult(null);
+ }
+ }
+
+ async function checkTextConnection() {
+ try {
+ setTextResult(
+ await textCheck.mutateAsync({
+ kind: "ai_chat",
+ body: form.buildConnectionCheckRequest([...TEXT_AI_KEYS]),
+ }),
+ );
+ } catch (error) {
+ setTextResult({
+ success: false,
+ message: error instanceof Error ? error.message : "Text AI connection check failed.",
+ });
+ }
+ }
+
+ async function checkSpeechConnection() {
+ try {
+ setSpeechResult(
+ await speechCheck.mutateAsync({
+ kind: "ai_transcription",
+ body: form.buildConnectionCheckRequest([...SPEECH_AI_KEYS]),
+ }),
+ );
+ } catch (error) {
+ setSpeechResult({
+ success: false,
+ message: error instanceof Error ? error.message : "Speech-to-text connection check failed.",
+ });
+ }
+ }
+
+ async function save() {
+ const batchSize = Number.parseInt(value("subtitle_ai.batch_size", "40"), 10);
+ const contextLines = Number.parseInt(value("subtitle_ai.context_neighbors", "2"), 10);
+ const chunkSeconds = Number.parseInt(value("subtitle_ai.asr_chunk_seconds", "600"), 10);
+ const quotaJobs = Number.parseInt(value("subtitle_ai.transcribe_quota_jobs", "0"), 10);
+ const maxConcurrent = Number.parseInt(
+ effectiveValue("ai.max_concurrent_jobs", "subtitle_ai.max_concurrent_jobs", "2"),
+ 10,
+ );
+
+ if (!textReady) {
+ toast.error("Text AI base URL and chat model are required.");
+ return;
+ }
+ if (!Number.isInteger(maxConcurrent) || maxConcurrent < 1) {
+ toast.error("Max concurrent jobs must be a positive whole number.");
+ return;
+ }
+ if (!Number.isInteger(batchSize) || batchSize < 1) {
+ toast.error("Subtitle batch size must be a positive whole number.");
+ return;
+ }
+ if (!Number.isInteger(contextLines) || contextLines < 0) {
+ toast.error("Subtitle context lines must be zero or a positive whole number.");
+ return;
+ }
+ if (!Number.isInteger(chunkSeconds) || chunkSeconds < 60 || chunkSeconds > 600) {
+ toast.error("Transcription chunk length must be between 60 and 600 seconds.");
+ return;
+ }
+ if (!Number.isInteger(quotaJobs) || quotaJobs < 0) {
+ toast.error("Transcription limit must be zero or a positive whole number.");
+ return;
+ }
+ await form.save();
+ }
+
return (
-
+
AI Services
- Shared AI endpoint and feature toggles for subtitle translation, subtitle generation from
- audio, and description translation.
+ Configure text translation and speech-to-text independently, then enable only the features
+ that use them.
-
-
-
+
+
+
+
+ setValue("ai.base_url", next)}
+ hint="https://api.openai.com"
+ />
+ setValue("ai.chat_model", next)}
+ hint="gpt-4o-mini, gemini-flash-latest, llama3.1"
+ />
+ setValue("ai.api_key", next)}
+ sensitiveConfigured={
+ form.sensitiveConfigured.includes("ai.api_key") ||
+ form.sensitiveConfigured.includes("subtitle_ai.api_key")
+ }
+ hint="Optional for keyless local endpoints. Saved keys are reused for tests only when the endpoint host is unchanged."
+ />
+
+
+
+
+
+
+
+
+ {TRANSCRIPTION_PRESETS.map((preset) => {
+ const active = asrBaseURL === preset.baseUrl && asrModel === preset.model;
+ return (
+
+ );
+ })}
+
+
setValue("ai.asr_base_url", next)}
+ hint="http://speaches:8000 or https://api.groq.com/openai"
+ />
+ {speechUsesTextEndpoint && (
+
+
+
+ Uses the Text translation endpoint and API key. This only works when that provider
+ implements OpenAI-compatible /audio/transcriptions with timestamped
+ segments. Test it before enabling audio generation.
+
+
+ )}
+ setValue("ai.asr_model", next)}
+ hint="whisper-large-v3-turbo or whisper-1"
+ />
+ setValue("ai.asr_api_key", next)}
+ sensitiveConfigured={form.sensitiveConfigured.includes("ai.asr_api_key")}
+ hint="Optional. A saved or inherited key is reused for tests only when the endpoint host is unchanged."
+ />
+
+ For self-hosted services, use a hostname or IP reachable from the Silo container.
+ localhost
+ points back to Silo itself.
+
+
+
+
+
+
+
+
Features
+
+ Generated subtitles and translated metadata are saved once and served to every client
+ through Silo's normal pipelines.
+
+
+
+
+
+ setValue("subtitle_ai.enabled", next)}
+ hint="Text AI required - Translates an existing text subtitle track. Whisper is not used."
+ />
+
+
+
+ setValue("subtitle_ai.transcribe_enabled", next)}
+ hint="Speech-to-text required - Uses Whisper to create timed subtitles from the selected audio track."
+ />
+
+
+
+ setValue("metadata_ai.enabled", next)}
+ hint="Text AI required - Translates overviews and taglines from the metadata editor or library refresh."
+ />
+
+
+
+ setValue("metadata_ai.on_view", next)}
+ disabled={!descriptionEnabled}
+ options={[
+ { value: "off", label: "Off" },
+ { value: "button", label: "Translate button on detail pages" },
+ { value: "auto", label: "Automatic on view" },
+ ]}
+ hint={
+ descriptionEnabled
+ ? "Controls viewer-triggered description translation."
+ : "Inactive until Description translation is enabled."
+ }
+ />
+
+
+
+
+
+
+
+
+
Advanced
+
+ Job concurrency, translation batching, transcription chunks, and account quotas.
+
+
+
+
+
+ setValue("ai.max_concurrent_jobs", next)}
+ hint="Shared by subtitle translation, speech-to-text, and description translation. Changing this value requires a server restart."
+ />
+ setValue("subtitle_ai.batch_size", next)}
+ hint="Text cues sent in each translation request."
+ />
+ setValue("subtitle_ai.context_neighbors", next)}
+ hint="Previous source cues included for scene continuity."
+ />
+ setValue("subtitle_ai.asr_chunk_seconds", next)}
+ hint="60-600. Shorter chunks reduce timestamp drift but make more requests."
+ />
+ setValue("subtitle_ai.transcribe_quota_jobs", next)}
+ hint="0 = unlimited. Profiles share their account's limit."
+ />
+ setValue("subtitle_ai.transcribe_quota_period", next)}
+ options={QUOTA_PERIODS.map((period) => ({
+ value: period,
+ label: `Per ${period} (rolling ${QUOTA_PERIOD_WINDOW_LABELS[period]})`,
+ }))}
+ hint="Rolling window used for the account limit."
+ />
+
+
+
+
+
+
+
Recommendation embeddings are configured separately
+
+ Search vectors and recommendations do not use the translation or speech endpoints above.
+
+
+
+ Open Recommendations
+
+
+
+
+
void save()}
+ onDiscard={form.discard}
+ isSaving={form.isSaving}
+ restartRequired={form.restartRequired}
+ />
);
}