Last active
July 20, 2026 08:54
-
-
Save naps62/c1432c66f6c2e666b3feedc158dd57fb to your computer and use it in GitHub Desktop.
post-processing script for translating subtitles to target lang after english download
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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