Skip to content

Instantly share code, notes, and snippets.

@naps62
Last active July 20, 2026 08:54
Show Gist options
  • Select an option

  • Save naps62/c1432c66f6c2e666b3feedc158dd57fb to your computer and use it in GitHub Desktop.

Select an option

Save naps62/c1432c66f6c2e666b3feedc158dd57fb to your computer and use it in GitHub Desktop.
post-processing script for translating subtitles to target lang after english download
#!/usr/bin/env bash
#
# Bazarr custom post-processing: translate a just-downloaded subtitle to a
# target language. Backend is chosen by which env var is set (precedence):
#
# LLAMA_URL set -> local LLM via llama.cpp / any OpenAI-compatible server
# (best quality; parses the .srt and translates cue-by-cue)
# DEEPL_API_KEY set -> DeepL document API
# otherwise -> Bazarr's own translate API (Google)
#
# All backends write "<stem>.<OUT_SUFFIX>.srt" beside the source (except the
# Bazarr path, which lets Bazarr name it). Guards below run for every backend.
#
# Set as: Settings > Subtitles > Custom Post-Processing, command:
# bash -c "$(curl -sL <RAW_GIST_URL> | sed 's/YOUR_API_KEY/<BAZARR_KEY>/')" -- \
# -p "{{subtitles}}" -s "{{series_id}}" -i "{{episode_id}}" \
# -l "{{subtitles_language_code2}}" -t "pt"
#
# --- Common env ---
# SOURCE_LANGS ISO-639-1 codes allowed as translation source. default: en
# OUT_SUFFIX Filename language token for output. default: -t value
#
# --- Local-LLM (llama.cpp) env ---
# LLAMA_URL Chat completions endpoint. Enables this path when set.
# e.g. http://10.6.10.x:8080/v1/chat/completions
# LLAMA_MODEL Model name to send (llama-server ignores it). default: local
# LLAMA_API_KEY Bearer token, if your server requires one. default: none
# LLM_TARGET Human name of target language for the prompt.
# default: European Portuguese
# LLM_SOURCE Human name of source language. default: derived from -l/en
# LLM_BATCH Cues per request. default: 25
#
# --- DeepL env ---
# DEEPL_API_KEY / DEEPL_TARGET (default PT-PT)
#
# --- Bazarr fallback env ---
# BAZARR_API_KEY (injected) / BAZARR_URL (default http://localhost:6767)
#
# Never deletes the source sub (deleting behind Bazarr triggers redownload loops).
set -euo pipefail
API_KEY="${BAZARR_API_KEY:-YOUR_API_KEY}"
BAZARR_URL="${BAZARR_URL:-http://localhost:6767}"
SOURCE_LANGS="${SOURCE_LANGS:-en}"
DEEPL_API_KEY="${DEEPL_API_KEY:-}"
DEEPL_TARGET="${DEEPL_TARGET:-PT-PT}"
LLAMA_URL="${LLAMA_URL:-}"
log() { echo "[translate.sh] $*"; }
usage() {
echo "Usage: $0 -p <subtitlePath> -t <targetLang> [-s <seriesId>] [-i <id>] [-l <sourceLangCode2>]" >&2
exit 1
}
# Bazarr invokes as: bash -c "<script>" -- -p ... ; strip the leading --
[[ "${1:-}" == "--" ]] && shift
[[ $# -eq 0 ]] && usage
path="" seriesId="" id="" targetLang="" sourceLang=""
while getopts p:s:i:t:l: flag; do
case "$flag" in
p) path=$OPTARG ;;
s) seriesId=$OPTARG ;;
i) id=$OPTARG ;;
t) targetLang=$OPTARG ;;
l) sourceLang=$OPTARG ;;
*) usage ;;
esac
done
[[ -z "$path" || -z "$targetLang" ]] && usage
OUT_SUFFIX="${OUT_SUFFIX:-$targetLang}"
# 1. Source-language guard: only translate FROM an allowed source language.
if [[ -n "$sourceLang" ]]; then
if ! grep -qiw -- "$sourceLang" <<<"$SOURCE_LANGS"; then
log "source '$sourceLang' not in SOURCE_LANGS ('$SOURCE_LANGS') — skip"
exit 0
fi
fi
# 2. Dedup guard: if a target-language sub already sits beside the file, skip.
dir=$(dirname -- "$path")
base=$(basename -- "$path")
stem=${base%.srt}
[[ -n "$sourceLang" ]] && stem=${stem%.$sourceLang} # strip trailing .<src>
shopt -s nullglob nocaseglob
for f in "$dir/$stem".*.srt; do
fb=$(basename -- "$f")
if [[ ".$fb" == *".$OUT_SUFFIX"* ]]; then
shopt -u nullglob nocaseglob
log "target '$OUT_SUFFIX' sub already present ($fb) — skip"
exit 0
fi
done
shopt -u nullglob nocaseglob
# ===========================================================================
# Local-LLM path (llama.cpp / OpenAI-compatible)
# ===========================================================================
if [[ -n "$LLAMA_URL" ]]; then
[[ -z "$sourceLang" ]] && { log "LLM path needs -l <sourceLangCode2> — aborting"; exit 1; }
[[ -f "$path" ]] || { log "source file not found: $path"; exit 1; }
command -v python3 >/dev/null || { log "python3 not found — aborting"; exit 1; }
out="$dir/$stem.$OUT_SUFFIX.srt"
# Map the -l code to a language name if the caller didn't set LLM_SOURCE.
case "${LLM_SOURCE:-}" in
"") case "$sourceLang" in
en) LLM_SOURCE="English" ;;
*) LLM_SOURCE="$sourceLang" ;;
esac ;;
esac
log "LLM: '$base' ($LLM_SOURCE) -> ${LLM_TARGET:-European Portuguese} ($(basename -- "$out"))"
LLAMA_URL="$LLAMA_URL" \
LLAMA_MODEL="${LLAMA_MODEL:-local}" \
LLAMA_API_KEY="${LLAMA_API_KEY:-}" \
LLM_TARGET="${LLM_TARGET:-European Portuguese}" \
LLM_SOURCE="$LLM_SOURCE" \
LLM_BATCH="${LLM_BATCH:-25}" \
python3 - "$path" "$out" <<'PY'
import json, os, re, sys, urllib.request
src_path, out_path = sys.argv[1], sys.argv[2]
URL = os.environ["LLAMA_URL"]
MODEL = os.environ.get("LLAMA_MODEL", "local")
API_KEY = os.environ.get("LLAMA_API_KEY", "")
TARGET = os.environ.get("LLM_TARGET", "European Portuguese")
SOURCE = os.environ.get("LLM_SOURCE", "English")
BATCH = max(1, int(os.environ.get("LLM_BATCH", "25")))
def die(msg, code=1):
print(f"[translate.sh][llm] {msg}", file=sys.stderr); sys.exit(code)
raw = open(src_path, encoding="utf-8-sig", errors="replace").read()
raw = raw.replace("\r\n", "\n").replace("\r", "\n")
# Parse into blocks: index / timecode / text (text may span lines).
blocks = []
for chunk in re.split(r"\n\s*\n", raw.strip()):
lines = chunk.split("\n")
idx = tc = None
if lines and lines[0].strip().isdigit():
idx = lines[0].strip(); lines = lines[1:]
if lines and "-->" in lines[0]:
tc = lines[0].strip(); lines = lines[1:]
blocks.append({"idx": idx, "tc": tc, "text": "\n".join(lines)})
# Which blocks actually carry translatable text.
todo = [i for i, b in enumerate(blocks) if b["tc"] and b["text"].strip()]
if not todo:
die("no translatable cues found")
SYS = (
f"You are a professional subtitle translator. Translate from {SOURCE} to "
f"{TARGET}. Rules: translate each item faithfully and naturally for on-screen "
"subtitles; keep it concise. Preserve HTML-like tags (e.g. <i></i>) and any "
"newline characters exactly as given. Do NOT add notes, numbering, or quotes. "
"Do NOT merge or split items. Return ONLY a JSON array of strings with EXACTLY "
"the same number of items and in the same order as the input array."
)
def chat(messages, timeout=180):
body = json.dumps({"model": MODEL, "messages": messages,
"temperature": 0.2, "stream": False}).encode("utf-8")
req = urllib.request.Request(URL, body, {"Content-Type": "application/json"})
if API_KEY:
req.add_header("Authorization", "Bearer " + API_KEY)
with urllib.request.urlopen(req, timeout=timeout) as r:
data = json.load(r)
return data["choices"][0]["message"]["content"]
def parse_array(text, n):
try:
arr = json.loads(text)
except Exception:
m = re.search(r"\[.*\]", text, re.S)
if not m:
return None
try:
arr = json.loads(m.group(0))
except Exception:
return None
if isinstance(arr, list) and len(arr) == n and all(isinstance(x, str) for x in arr):
return arr
return None
def translate_batch(texts):
msgs = [{"role": "system", "content": SYS},
{"role": "user", "content": json.dumps(texts, ensure_ascii=False)}]
arr = parse_array(chat(msgs), len(texts))
if arr is not None:
return arr
# one stricter retry
msgs.append({"role": "user",
"content": f"Return ONLY a JSON array of exactly {len(texts)} strings."})
arr = parse_array(chat(msgs), len(texts))
if arr is not None:
return arr
# last resort: per-item (guarantees 1:1; keeps original on failure)
out = []
for t in texts:
one = parse_array(chat([{"role": "system", "content": SYS},
{"role": "user",
"content": json.dumps([t], ensure_ascii=False)}]), 1)
out.append(one[0] if one else t)
return out
done = 0
for start in range(0, len(todo), BATCH):
group = todo[start:start + BATCH]
texts = [blocks[i]["text"] for i in group]
try:
res = translate_batch(texts)
except Exception as e:
die(f"request failed: {e}")
for i, tr in zip(group, res):
blocks[i]["text"] = tr
done += len(group)
print(f"[translate.sh][llm] {done}/{len(todo)} cues", file=sys.stderr)
# Reassemble SRT (renumber sequentially, keep original timecodes).
out_lines, n = [], 0
for b in blocks:
if not b["tc"]:
continue
n += 1
out_lines.append(str(n))
out_lines.append(b["tc"])
out_lines.append(b["text"])
out_lines.append("")
with open(out_path, "w", encoding="utf-8") as fh:
fh.write("\n".join(out_lines).rstrip("\n") + "\n")
print(f"[translate.sh][llm] wrote {out_path}", file=sys.stderr)
PY
rc=$?
if [[ $rc -eq 0 && -s "$out" ]]; then
log "ok -> $out"
exit 0
fi
log "LLM translation failed (rc=$rc)"
exit 1
fi
# ===========================================================================
# DeepL document API path
# ===========================================================================
if [[ -n "$DEEPL_API_KEY" ]]; then
[[ -z "$sourceLang" ]] && { log "DeepL path needs -l <sourceLangCode2> — aborting"; exit 1; }
[[ -f "$path" ]] || { log "source file not found: $path"; exit 1; }
if [[ "$DEEPL_API_KEY" == *:fx ]]; then deepl="https://api-free.deepl.com/v2"
else deepl="https://api.deepl.com/v2"; fi
auth="Authorization: DeepL-Auth-Key $DEEPL_API_KEY"
out="$dir/$stem.$OUT_SUFFIX.srt"
jstr() { sed -n 's/.*"'"$1"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p'; }
log "DeepL: '$base' -> $DEEPL_TARGET ($(basename -- "$out"))"
up=$(curl -s -H "$auth" -F "file=@$path;type=application/x-subrip" \
-F "target_lang=$DEEPL_TARGET" "$deepl/document") || { log "DeepL upload failed"; exit 1; }
doc_id=$(printf '%s' "$up" | jstr document_id)
doc_key=$(printf '%s' "$up" | jstr document_key)
[[ -z "$doc_id" || -z "$doc_key" ]] && { log "DeepL upload rejected: $up"; exit 1; }
status=""
for _ in $(seq 1 30); do
st=$(curl -s -H "$auth" --data-urlencode "document_key=$doc_key" \
"$deepl/document/$doc_id") || true
status=$(printf '%s' "$st" | jstr status)
case "$status" in done) break ;; error) log "DeepL error: $st"; exit 1 ;; esac
sleep 2
done
[[ "$status" == "done" ]] || { log "DeepL timed out (last='${status:-none}')"; exit 1; }
tmp=$(mktemp)
code=$(curl -s -o "$tmp" -w '%{http_code}' -H "$auth" \
--data-urlencode "document_key=$doc_key" "$deepl/document/$doc_id/result") || code="000"
if [[ "$code" =~ ^2 ]] && [[ -s "$tmp" ]]; then
mv -f "$tmp" "$out"; log "ok -> $out"; exit 0
fi
rm -f "$tmp"; log "DeepL result download failed ($code)"; exit 1
fi
# ===========================================================================
# Fallback: Bazarr's own translate API (Google)
# ===========================================================================
type="movie"; [[ -n "$seriesId" ]] && type="episode"
encPath=$(printf '%s' "$path" | sed 's/\//%2F/g; s/ /%20/g')
url="$BAZARR_URL/api/subtitles?action=translate&language=$targetLang&path=$encPath&type=$type&id=$id"
log "Bazarr-translate: '$base' -> $targetLang"
http_code=$(curl -s -o /tmp/bazarr-translate.out -w '%{http_code}' \
-X PATCH "$url" -H 'accept: application/json' -H "X-API-KEY: $API_KEY") || http_code="000"
if [[ "$http_code" =~ ^2 ]]; then
log "ok ($http_code)"
else
log "FAILED ($http_code): $(cat /tmp/bazarr-translate.out 2>/dev/null)"
exit 1
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment