fix(plex): a sidecar subtitle in a legacy code page rendered its accents as U+FFFD

A Hungarian .srt beside the video (Windows-1250) was decoded as UTF-8 with
errors=replace: one episode served 1391 U+FFFD and no accented letter.
decode_subtitle now lets a BOM decide, keeps valid UTF-8, and otherwise
reads the legacy code page of the stream's language (cp1250 for Central
European, cp1251 for Cyrillic, else cp1252) — on the ffmpeg path too.

The cache has no eviction, so its key is versioned (sub2_): a track cached
broken is read again once instead of being served for good.
This commit is contained in:
2026-09-25 15:20:31 +02:00
parent 58878958d8
commit 1d8b83c37c
3 changed files with 133 additions and 6 deletions
+49 -4
View File
@@ -32,6 +32,7 @@ window still took the full 145 s and emitted the whole track), so windows are ex
zero-based clock and shifted back to absolute time here with `shift_vtt`.
"""
import codecs
import json
import re
import subprocess
@@ -217,7 +218,10 @@ _FULL_END_S = 1_000_000_000.0 # stand-in for "to the end of the file" (JSON can
def cache_key(rating_key: str, sub_ord: int) -> str:
return f"sub_{rating_key}_{sub_ord}"
# `sub2_`: versioned when sidecars in a legacy code page turned out to have been cached as
# U+FFFD (`decode_subtitle`). The cache has no eviction, so every track is read again once
# rather than a broken one being served for good; the old `sub_` files are just left behind.
return f"sub2_{rating_key}_{sub_ord}"
def _vtt_path(key: str) -> Path:
@@ -904,15 +908,56 @@ def _full_pass_start(rating_key: str, targets: dict[int, int]) -> float:
return max(0.0, float(f"{min(ends) - WINDOW_PRE_S:.3f}"))
# The legacy code page a subtitle in this language (Plex's ISO 639-2 `languageCode`) was most likely
# saved in; anything not listed falls to Windows-1252.
_CP1250 = {
"hun",
"ces",
"cze",
"pol",
"slk",
"slo",
"slv",
"hrv",
"bos",
"ron",
"rum",
"sqi",
"alb",
}
_CP1251 = {"rus", "ukr", "bel", "bul", "mkd", "mac", "srp"}
def decode_subtitle(raw: bytes, language_code: str | None) -> str:
"""A sidecar subtitle file as text. SubRip carries no charset, and a Hungarian release is
typically Windows-1250: decoded as UTF-8 with `replace`, every ő/ű/é came out as U+FFFD (found
live, 1391 of them in one episode). So: a BOM decides; else valid UTF-8 stays UTF-8 (a legacy
code page with accents is almost never valid UTF-8); else the language's legacy code page."""
if raw.startswith(codecs.BOM_UTF8):
return raw[len(codecs.BOM_UTF8) :].decode("utf-8", "replace")
if raw.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
return raw.decode("utf-16", "replace")
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
# silent-ok: not UTF-8 is the expected answer for a legacy-code-page file, decoded below.
pass
lang = (language_code or "").lower()
legacy = "cp1250" if lang in _CP1250 else "cp1251" if lang in _CP1251 else "cp1252"
return raw.decode(legacy, "replace")
def _sidecar_vtt(db: Session, stream: dict) -> str:
"""An external subtitle file, fetched through Plex and converted to WebVTT."""
"""An external subtitle file, fetched through Plex and converted to WebVTT. Its bytes are made
UTF-8 first (`decode_subtitle`), on the ffmpeg path too: ffmpeg reads a text subtitle as UTF-8."""
with PlexClient(db) as plex:
raw = plex.raw_get(stream["key"])
text = decode_subtitle(raw, stream.get("languageCode"))
codec = (stream.get("codec") or stream.get("format") or "").lower()
if codec in ("srt", "subrip", ""):
return srt_to_vtt(raw.decode("utf-8", "replace"))
return srt_to_vtt(text)
with tempfile.NamedTemporaryFile(suffix="." + (codec or "sub"), delete=False) as fh:
fh.write(raw)
fh.write(text.encode("utf-8"))
tmp = fh.name
try:
return ffmpeg_to_vtt(tmp, None)