diff --git a/internal/subtitles/ai/service.go b/internal/subtitles/ai/service.go
index e7d773f9..ffbacf49 100644
--- a/internal/subtitles/ai/service.go
+++ b/internal/subtitles/ai/service.go
@@ -279,6 +279,14 @@ func (s *Service) dispatch(job Job) {
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{}{}:
@@ -292,27 +300,25 @@ func (s *Service) dispatch(job Job) {
}()
}
-func (s *Service) run(ctx context.Context, job *Job) {
- // Keep heartbeat_at fresh while the job runs (progress updates also bump it),
- // so the stale-job reaper never resets a job that is still alive during a long
- // single LLM call.
- stopHeartbeat := make(chan struct{})
- defer close(stopHeartbeat)
- go func() {
- ticker := time.NewTicker(heartbeatInterval)
- defer ticker.Stop()
- for {
- select {
- case <-stopHeartbeat:
- return
- case <-ctx.Done():
- return
- case <-ticker.C:
- _ = s.repo.Heartbeat(context.WithoutCancel(ctx), job.ID)
- }
+// 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)
}
- }()
+ }
+}
+func (s *Service) run(ctx context.Context, job *Job) {
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)
}
diff --git a/web/src/pages/admin-settings/IntegrationsSettings.tsx b/web/src/pages/admin-settings/IntegrationsSettings.tsx
index 2088e3e7..ce49d782 100644
--- a/web/src/pages/admin-settings/IntegrationsSettings.tsx
+++ b/web/src/pages/admin-settings/IntegrationsSettings.tsx
@@ -536,6 +536,9 @@ function AISubtitleTranslationCard() {
+
+ Changes take effect after a server restart.
+
);
diff --git a/web/src/player/components/SubtitleTranslateModal.test.ts b/web/src/player/components/SubtitleTranslateModal.test.ts
new file mode 100644
index 00000000..a1510887
--- /dev/null
+++ b/web/src/player/components/SubtitleTranslateModal.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vitest";
+import { isTranslatableSource } from "./SubtitleTranslateModal";
+import type { PlayerSubtitleInfo } from "../types";
+
+function track(p: Partial): PlayerSubtitleInfo {
+ return { index: 0, language: "en", label: "", url: "", ...p };
+}
+
+describe("isTranslatableSource", () => {
+ it("accepts text external/downloaded subtitles", () => {
+ expect(isTranslatableSource(track({ source: "external", codec: "srt" }))).toBe(true);
+ expect(isTranslatableSource(track({ source: "downloaded", codec: "subrip" }))).toBe(true);
+ expect(isTranslatableSource(track({ source: "external", codec: "vtt" }))).toBe(true);
+ });
+
+ it("rejects ASS/SSA external/downloaded subtitles the server can't parse", () => {
+ expect(isTranslatableSource(track({ source: "external", codec: "ass" }))).toBe(false);
+ expect(isTranslatableSource(track({ source: "downloaded", codec: "ssa" }))).toBe(false);
+ });
+
+ it("accepts non-bitmap embedded tracks (extracted via ffmpeg, incl. ASS)", () => {
+ expect(isTranslatableSource(track({ source: "embedded", codec: "ass" }))).toBe(true);
+ expect(isTranslatableSource(track({ source: "embedded", codec: "subrip" }))).toBe(true);
+ });
+
+ it("rejects bitmap embedded tracks", () => {
+ expect(isTranslatableSource(track({ source: "embedded", codec: "hdmv_pgs_subtitle" }))).toBe(
+ false,
+ );
+ expect(isTranslatableSource(track({ source: "embedded", codec: "dvd_subtitle" }))).toBe(false);
+ });
+
+ it("rejects the in-progress live track", () => {
+ expect(isTranslatableSource(track({ source: "downloaded", codec: "srt", live: true }))).toBe(
+ false,
+ );
+ });
+});
diff --git a/web/src/player/components/SubtitleTranslateModal.tsx b/web/src/player/components/SubtitleTranslateModal.tsx
index 350aa6a1..b702bced 100644
--- a/web/src/player/components/SubtitleTranslateModal.tsx
+++ b/web/src/player/components/SubtitleTranslateModal.tsx
@@ -1,10 +1,29 @@
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 { playerFetch } from "../player-fetch";
import { LANGUAGES, getLanguageName } from "../utils/languageNames";
+// Formats the server can parse directly from an external/downloaded file.
+const TRANSLATABLE_TEXT_CODECS = new Set(["srt", "subrip", "vtt", "webvtt"]);
+// Bitmap codecs the server can't extract as text (it would burn them in).
+const BITMAP_CODECS = new Set(["pgs", "hdmv_pgs_subtitle", "dvd_subtitle", "dvb_subtitle"]);
+
+// Mirror the server's loadSource acceptance: embedded non-bitmap tracks are
+// extracted to text via ffmpeg, while external/downloaded sources must already
+// be a parseable text format. Offering anything else starts a job that fails
+// asynchronously instead of preventing the choice up front.
+export function isTranslatableSource(track: PlayerSubtitleInfo): boolean {
+ if (track.live) return false;
+ const codec = (track.codec ?? "").toLowerCase();
+ if (track.source === "embedded") {
+ return !BITMAP_CODECS.has(codec);
+ }
+ return TRANSLATABLE_TEXT_CODECS.has(codec);
+}
+
interface SubtitleTranslateModalProps {
mediaFileId: number;
playerConfig: PlayerConfig;
@@ -30,8 +49,9 @@ export function SubtitleTranslateModal({
getStartPosition,
onClose,
}: SubtitleTranslateModalProps) {
- // Live (in-progress) tracks can't be a translation source.
- const sourceTracks = useMemo(() => tracks.filter((t) => !t.live), [tracks]);
+ // 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 [sourceIndex, setSourceIndex] = useState(null);
const [targetLang, setTargetLang] = useState("en");
const [submitting, setSubmitting] = useState(false);
@@ -54,18 +74,30 @@ export function SubtitleTranslateModal({
setSubmitting(true);
setError(null);
try {
- await playerFetch(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,
- }),
- });
- // The player takes over from here: it pauses, streams cues in as they're
+ 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,
+ }),
+ },
+ );
+ // 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.",
+ );
+ }
+ // Otherwise the player takes over: it pauses, streams cues in as they're
// translated, then resumes once your position is covered.
onClose();
} catch (err) {