"""Single source of truth for subtitle filename parsing.
Sidecar subtitles in this project follow the pattern
.(.)*.
where:
* ``base`` is the video filename without its container extension
(e.g. ``"Show - S01E02 - Episode Title"``)
* ``lang`` is an ISO-639-1/2 language code (2-3 lowercase letters)
* ``modifier`` is an optional descriptor from MODIFIERS — multiple
modifiers can stack (``en.hi.bak.srt`` = the HI variant
of an English sub that the user later modified, with a
backup of the original HI version)
* ``ext`` is one of SUBTITLE_EXTS
Modifier semantics:
* ``bak`` = backup created by ``subtitle_processor.apply_mods``
before HI-removal / common-fixes / credit-removal.
These files MUST be hidden from the active subtitle
list — they are restore artefacts, not playable subs.
* ``hi`` = Hearing Impaired (extra annotations like *[door slams]*)
* ``sdh`` = Subtitles for the Deaf and Hard-of-hearing
* ``forced`` = forced subs (foreign-language passages only)
* ``cc`` = Closed Captions (US convention; treated like SDH)
The parser uses a right-to-left "suffix stripper" so that arbitrary
modifier stacks are accepted without growing the regex. This was the
explicit design choice over a single regex (per Gemini Pro review on
2026-05-01) — single regex needs an exponential alternation list to
cover modifier orderings, the stripper handles N modifiers in O(N).
Backup file location
--------------------
``.bak`` backup sidecars live in a hidden ``.sublarr/backups/`` subdir of
the active sub's directory — NOT next to the active file. The hidden
location keeps Plex/Emby/Jellyfin from picking them up: those servers
parse the segment immediately preceding ``.srt`` as a language code, and
``bak`` is a valid ISO-639-2/3 code for Bashkir. Storing backups in a
hidden subdir bypasses that misparse entirely.
Use :func:`bak_path_for` and :func:`active_path_for_bak` to translate
between the two — never construct ``f"{base}.bak{ext}"`` strings inline.
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass
# Hidden subdir layout for backup sidecars. Lives directly inside each
# series/movie folder so a backup stays adjacent to the file it protects
# without being visible to media servers. ``.sublarr/`` is also used by
# the remux pipeline for container backups (``.sublarr/trash/``); the
# walker in ``services.subtitle_backups`` knows to descend into
# ``.sublarr/backups/`` while still skipping the trash subdir.
BAK_HIDDEN_DIR = ".sublarr"
BAK_SUBDIR = "backups"
# Subtitle file extensions recognised by Sublarr. Keep aligned with
# routes.subtitles.helpers._SUBTITLE_EXTS and dedup_engine.SUBTITLE_EXTENSIONS.
SUBTITLE_EXTS: frozenset[str] = frozenset({"srt", "ass", "ssa", "vtt"})
# Modifier tokens that may appear between the language and the extension.
# Order does not matter; the parser collects them all into a sorted list.
# Keep aligned with the regex in dedup_engine.py.
# NOTE: deliberately diverges from remux._SIDECAR_MODIFIERS (has bak, lacks
# sign/signs; stacks unbounded vs. single trailing token there). The remux
# table's divergences all err toward NOT parsing = never trashing.
MODIFIERS: frozenset[str] = frozenset({"hi", "bak", "forced", "sdh", "cc", "sign", "signs"})
# Lowercase ISO-639-1 (2 letters) and ISO-639-2 (3 letters). The lookup
# table in config_language_data is the authoritative list, but for
# filename parsing we just need a syntactic check — anything outside
# this shape is rejected so accidental "Mr. Saturday.srt"-like names
# never produce ``language="rd"``.
_LANG_RE = re.compile(r"^[a-z]{2,3}$")
@dataclass(frozen=True)
class ParsedFilename:
"""Result of :func:`parse_subtitle_filename`.
``modifiers`` is a tuple of zero or more modifier tokens in the order
they appear in the filename (rightmost first as the parser pops
them). ``is_backup`` is a convenience for the most common consumer
check — equivalent to ``"bak" in modifiers``.
"""
base: str
language: str
modifiers: tuple[str, ...]
extension: str
@property
def is_backup(self) -> bool:
return "bak" in self.modifiers
@property
def primary_modifier(self) -> str | None:
"""The non-bak modifier if any (UI badge candidate).
``bak`` is excluded because callers that surface it never display
it as a sidecar in the first place. ``None`` when the file has
no display-relevant modifier.
"""
for m in self.modifiers:
if m != "bak":
return m
return None
def _is_known_language_tag(token: str) -> bool:
"""A tag the central table knows although it is no 2-3 letter code:
"german", "pt-br", "zh-hans". The cleanup already read those as languages
while the listing did not show the file at all (#61 A23).
"""
from config_language_data import _REVERSE_LANGUAGE_TAGS
return token in _REVERSE_LANGUAGE_TAGS
def parse_subtitle_filename(path: str) -> ParsedFilename | None:
"""Parse a subtitle sidecar path into its components.
Returns ``None`` when the filename does not match the
``.(.)*.`` shape. Callers that just want
to know "is this a subtitle sidecar" can rely on a non-``None``
return value.
Examples
--------
>>> r = parse_subtitle_filename("/m/Show.S01E01.de.srt")
>>> r.language, r.modifiers, r.is_backup
('de', (), False)
>>> r = parse_subtitle_filename("/m/Show.S01E01.en.bak.srt")
>>> r.language, r.modifiers, r.is_backup
('en', ('bak',), True)
>>> r = parse_subtitle_filename("/m/Show.S01E01.en.hi.bak.srt")
>>> r.language, r.modifiers, r.is_backup, r.primary_modifier
('en', ('bak', 'hi'), True, 'hi')
>>> parse_subtitle_filename("/m/Show.S01E01.mkv") is None
True
>>> parse_subtitle_filename("/m/Show.S01E01.srt") is None # no language
True
"""
filename = os.path.basename(path)
parts = filename.split(".")
if len(parts) < 3:
# Need at least .. — no modifier-less, no-lang
# files like "subs.srt" qualify as sidecars.
return None
# 1. Pop extension
ext = parts[-1].lower()
if ext not in SUBTITLE_EXTS:
return None
parts = parts[:-1]
# 2. Peel modifiers right-to-left (stack like ``.hi.bak``)
modifiers: list[str] = []
while parts and parts[-1].lower() in MODIFIERS:
modifiers.append(parts.pop().lower())
# 3. The next-rightmost token must be a language code
if not parts:
return None
lang_candidate = parts[-1].lower()
if not (_LANG_RE.match(lang_candidate) or _is_known_language_tag(lang_candidate)):
return None
parts.pop()
base = ".".join(parts) if parts else ""
if not base:
# Reject ``.de.srt`` (no base) — almost certainly not a real
# sidecar, more likely a malformed filename.
return None
return ParsedFilename(
base=base,
language=lang_candidate,
modifiers=tuple(modifiers),
extension=ext,
)
# Marker segment for combined/bilingual output (V1.6 #1). A combined file is
# named ``.-[-].combined.``. It deliberately does NOT
# match parse_subtitle_filename (the dual "de-en" tag is not a language code),
# so the keep-langs cleanup never trashes it. This additive parser lets the
# scanner surface it as an editable sidecar without changing that protection.
COMBINED_MARKER = "combined"
def parse_combined_filename(path: str) -> tuple[str, str] | None:
"""Parse a combined sidecar path into ``(language_tag, extension)``.
``language_tag`` is the hyphen-joined set of language codes (e.g. ``"de-en"``).
Returns ``None`` when ``path`` is not a ``.-[-…].combined.``
combined sidecar.
"""
parts = os.path.basename(path).split(".")
if len(parts) < 4:
return None
ext = parts[-1].lower()
if ext not in SUBTITLE_EXTS:
return None
if parts[-2].lower() != COMBINED_MARKER:
return None
lang_tag = parts[-3]
langs = lang_tag.split("-")
if len(langs) < 2 or not all(_LANG_RE.match(code.lower()) for code in langs):
return None
base = ".".join(parts[:-3])
if not base:
return None
return (lang_tag, ext)
def is_subtitle_sidecar(path: str) -> bool:
"""Return True when ``path`` looks like a real subtitle sidecar.
Convenience wrapper for callers that don't need the parsed parts.
"""
return parse_subtitle_filename(path) is not None
def is_backup_subtitle(path: str) -> bool:
"""Return True when ``path`` is a ``.bak`` backup of another sidecar.
Used by the scanner and list endpoints to decide whether the file
should be hidden from the active-subtitle UI.
"""
parsed = parse_subtitle_filename(path)
return parsed is not None and parsed.is_backup
# ---------------------------------------------------------------------------
# Backup path helpers
# ---------------------------------------------------------------------------
def bak_dir_for(active_path: str) -> str:
"""Return the hidden backup directory beside ``active_path``."""
return os.path.join(os.path.dirname(active_path), BAK_HIDDEN_DIR, BAK_SUBDIR)
def bak_path_for(active_path: str) -> str:
"""Return the canonical ``.bak`` target path for ``active_path``.
Always points into the hidden ``.sublarr/backups/`` subdir of the
active sidecar's directory. The basename keeps the historical
``.(.)*.bak.`` shape so existing parsers
keep working — only the location changes.
Example::
bak_path_for("/m/Show/S01E01.en.srt")
-> "/m/Show/.sublarr/backups/S01E01.en.bak.srt"
"""
base, ext = os.path.splitext(os.path.basename(active_path))
bak_name = f"{base}.bak{ext}"
return os.path.join(bak_dir_for(active_path), bak_name)
def legacy_bak_path_for(active_path: str) -> str:
"""Return the legacy sibling-location bak path for ``active_path``.
Pre-2026-05-02 backups were written next to the active sub at
``..bak.``. The migration script in
``backend/scripts/migrate_subtitle_baks.py`` moves these into the
hidden subdir; readers (undo / bak-exists / restore listings) check
both locations during the transition window.
"""
base, ext = os.path.splitext(active_path)
return f"{base}.bak{ext}"
def find_existing_bak(active_path: str) -> str | None:
"""Return the path of an existing bak for ``active_path``, or None.
Checks the canonical hidden location first, then falls back to the
legacy sibling location. Use this in restore / exists endpoints so
pre-migration files keep working.
"""
new_path = bak_path_for(active_path)
if os.path.exists(new_path):
return new_path
legacy = legacy_bak_path_for(active_path)
if os.path.exists(legacy):
return legacy
return None
def active_path_for_bak(bak_path: str) -> str | None:
"""Reverse of :func:`bak_path_for`: where would this bak restore to.
Handles both layouts so pre-migration files keep working:
* new — ``/.sublarr/backups/..bak.``
* legacy — ``/..bak.`` (sibling)
Returns ``None`` when the basename is not a valid backup sidecar.
"""
parsed = parse_subtitle_filename(bak_path)
if parsed is None or not parsed.is_backup:
return None
other_mods = [m for m in parsed.modifiers if m != "bak"]
parts: list[str] = [parsed.base, parsed.language, *other_mods, parsed.extension]
new_name = ".".join(parts)
parent = os.path.dirname(bak_path)
if (
os.path.basename(parent) == BAK_SUBDIR
and os.path.basename(os.path.dirname(parent)) == BAK_HIDDEN_DIR
):
# New layout: strip the .sublarr/backups/ pair to land in the
# series folder where the active sub lives.
media_dir = os.path.dirname(os.path.dirname(parent))
else:
# Legacy: bak is a sibling of the active sub.
media_dir = parent
return os.path.join(media_dir, new_name)