backend/compat_checker.py
Rama master · réplica de lectura del repositorio original.
"""Subtitle compatibility checker for Plex and Kodi media players.
Stateless validation functions that check subtitle file naming and placement
against media player conventions. Pure functions -- no Flask dependencies.
Plex docs: https://support.plex.tv/articles/200471133-adding-local-subtitles-to-your-media/
Kodi docs: https://kodi.wiki/view/Subtitles
"""
import os
from compat_checker_data import _ISO_639_1, _ISO_639_2, _LANGUAGE_NAMES
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
def _extract_lang_code(subtitle_filename: str) -> str | None:
"""Extract language code from a subtitle filename.
Supports patterns:
- basename.lang.ext (e.g., Movie.en.srt)
- basename.lang.forced.ext
- basename.lang.sdh.ext
- basename.lang.hi.ext
Returns:
The language code string, or None if not found.
"""
name = os.path.splitext(subtitle_filename)[0] # remove extension
# Split by dots to find potential lang code
parts = name.rsplit(".", 3) # up to 3 from end
if len(parts) < 2:
return None
# Check for modifiers: forced, sdh, hi, cc
modifiers = {"forced", "sdh", "hi", "cc"}
# Check last part (before extension was removed)
candidate = parts[-1].lower()
if candidate in modifiers and len(parts) >= 3:
# The lang code is one position further back
return parts[-2]
else:
return parts[-1]
def _is_valid_iso639(code: str) -> bool:
"""Check if a code is a valid ISO 639-1 or 639-2 language code."""
lower = code.lower()
return lower in _ISO_639_1 or lower in _ISO_639_2
def _is_valid_bcp47_or_name(code: str) -> bool:
"""Check if a code is a valid BCP 47 tag (with _ separator) or English language name.
Kodi accepts:
- ISO 639-1 (en, de)
- ISO 639-2 (eng, deu)
- BCP 47 with _ separator (pt_BR, zh_Hans)
- English language name (English, German)
"""
if _is_valid_iso639(code):
return True
# BCP 47 with underscore separator (e.g., pt_BR, zh_Hans)
if "_" in code:
base = code.split("_")[0]
return _is_valid_iso639(base)
return code.lower() in _LANGUAGE_NAMES
def _check_naming_match(sub_basename: str, video_basename: str) -> bool:
"""Check if subtitle basename (before lang code) matches video basename (before ext).
Example: "Movie (2024)" from "Movie (2024).en.srt" matches
"Movie (2024)" from "Movie (2024).mkv"
"""
return sub_basename.lower() == video_basename.lower()
def _get_video_basename(video_path: str) -> str:
"""Get the basename of a video file (name without extension)."""
return os.path.splitext(os.path.basename(video_path))[0]
def _get_sub_basename(subtitle_filename: str) -> str:
"""Get the basename of a subtitle file (name without lang code and extension).
Strips extension, then strips lang code and any modifiers (forced, sdh, hi, cc).
"""
name = os.path.splitext(subtitle_filename)[0] # remove .srt/.ass etc.
parts = name.rsplit(".", 3)
if len(parts) < 2:
return name
modifiers = {"forced", "sdh", "hi", "cc"}
# Check how many trailing parts are modifiers or lang codes
strip_count = 0
for i in range(len(parts) - 1, 0, -1):
part_lower = parts[i].lower()
if (
part_lower in modifiers
or _is_valid_iso639(part_lower)
or _is_valid_bcp47_or_name(part_lower)
):
strip_count += 1
else:
break
if strip_count > 0:
return ".".join(parts[: len(parts) - strip_count])
return name
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def check_plex_compatibility(subtitle_path: str, video_path: str) -> dict:
"""Validate subtitle naming and placement against Plex conventions.
Checks:
a. Language code format (ISO 639-1 or 639-2, lowercase recommended)
b. File extension (.srt, .ass, .ssa, .vtt, .smi)
c. Placement (same dir as video, or Subtitles/Subs subfolder)
d. Naming match (subtitle basename matches video basename)
e. Uppercase language code warning
Args:
subtitle_path: Path to the subtitle file.
video_path: Path to the video file.
Returns:
Dict with compatible (bool), issues (list), warnings (list),
recommendations (list).
"""
result = {
"compatible": True,
"issues": [],
"warnings": [],
"recommendations": [],
}
sub_filename = os.path.basename(subtitle_path)
sub_dir = os.path.dirname(os.path.abspath(subtitle_path))
video_dir = os.path.dirname(os.path.abspath(video_path))
video_basename = _get_video_basename(video_path)
sub_basename = _get_sub_basename(sub_filename)
# a. Language code format
lang_code = _extract_lang_code(sub_filename)
if lang_code is None:
result["issues"].append("No language code found in subtitle filename")
result["compatible"] = False
elif not _is_valid_iso639(lang_code):
result["issues"].append(
f"Language code '{lang_code}' is not a valid ISO 639-1 or 639-2 code"
)
result["compatible"] = False
else:
# e. Uppercase warning
if lang_code != lang_code.lower():
result["warnings"].append(
f"Language code '{lang_code}' is not lowercase. "
"Plex is case-sensitive on Linux; recommend lowercase for cross-platform compatibility."
)
# b. File extension
_, ext = os.path.splitext(sub_filename)
plex_extensions = {".srt", ".ass", ".ssa", ".vtt", ".smi"}
if ext.lower() not in plex_extensions:
result["issues"].append(
f"File extension '{ext}' is not supported by Plex. "
f"Supported: {', '.join(sorted(plex_extensions))}"
)
result["compatible"] = False
# c. Placement: same directory or Subtitles/Subs subfolder
if sub_dir != video_dir:
# Check if subtitle is in a Subtitles or Subs subfolder relative to video
relative = os.path.relpath(sub_dir, video_dir)
valid_subfolders = {"subtitles", "subs"}
if relative.lower() not in valid_subfolders:
result["issues"].append(
f"Subtitle is not in the same directory as the video or a "
f"'Subtitles'/'Subs' subfolder. Relative path: '{relative}'"
)
result["compatible"] = False
# d. Naming match
if not _check_naming_match(sub_basename, video_basename):
result["issues"].append(
f"Subtitle basename '{sub_basename}' does not match video basename '{video_basename}'"
)
result["compatible"] = False
# Recommendations
if not result["issues"] and not result["warnings"]:
result["recommendations"].append("Subtitle is fully Plex-compatible")
return result
def check_kodi_compatibility(subtitle_path: str, video_path: str) -> dict:
"""Validate subtitle naming and placement against Kodi conventions.
Checks:
a. Language code (ISO 639-1, 639-2, BCP 47 with _, or English name)
b. File extension (.srt, .ass, .ssa, .sub, .smi)
c. Placement (must be same directory as video)
d. Naming match
Args:
subtitle_path: Path to the subtitle file.
video_path: Path to the video file.
Returns:
Dict with compatible (bool), issues (list), warnings (list),
recommendations (list).
"""
result = {
"compatible": True,
"issues": [],
"warnings": [],
"recommendations": [],
}
sub_filename = os.path.basename(subtitle_path)
sub_dir = os.path.dirname(os.path.abspath(subtitle_path))
video_dir = os.path.dirname(os.path.abspath(video_path))
video_basename = _get_video_basename(video_path)
sub_basename = _get_sub_basename(sub_filename)
# a. Language code
lang_code = _extract_lang_code(sub_filename)
if lang_code is None:
result["issues"].append("No language code found in subtitle filename")
result["compatible"] = False
elif not _is_valid_bcp47_or_name(lang_code):
result["issues"].append(
f"Language code '{lang_code}' is not a valid ISO 639-1, ISO 639-2, "
f"BCP 47 (with _ separator), or English language name"
)
result["compatible"] = False
# b. File extension
_, ext = os.path.splitext(sub_filename)
kodi_extensions = {".srt", ".ass", ".ssa", ".sub", ".smi"}
if ext.lower() not in kodi_extensions:
result["issues"].append(
f"File extension '{ext}' is not supported by Kodi. "
f"Supported: {', '.join(sorted(kodi_extensions))}"
)
result["compatible"] = False
# c. Placement: must be same directory as video (Kodi does not support subfolder)
if sub_dir != video_dir:
result["issues"].append(
"Subtitle must be in the same directory as the video file. "
"Kodi does not support subtitle subfolders."
)
result["compatible"] = False
# d. Naming match
if not _check_naming_match(sub_basename, video_basename):
result["issues"].append(
f"Subtitle basename '{sub_basename}' does not match video basename '{video_basename}'"
)
result["compatible"] = False
# Recommendations
if not result["issues"] and not result["warnings"]:
result["recommendations"].append("Subtitle is fully Kodi-compatible")
return result
def batch_check_compatibility(
subtitle_paths: list,
video_path: str,
target: str = "plex",
) -> dict:
"""Run compatibility check on multiple subtitle files.
Args:
subtitle_paths: List of subtitle file paths to check.
video_path: Path to the associated video file.
target: Target media player ("plex" or "kodi").
Returns:
Dict with results (list of individual results) and summary
(total, compatible, incompatible counts).
"""
checker = check_plex_compatibility if target == "plex" else check_kodi_compatibility
results = []
compatible_count = 0
incompatible_count = 0
for sub_path in subtitle_paths:
check_result = checker(sub_path, video_path)
results.append(
{
"subtitle_path": sub_path,
**check_result,
}
)
if check_result["compatible"]:
compatible_count += 1
else:
incompatible_count += 1
return {
"results": results,
"summary": {
"total": len(subtitle_paths),
"compatible": compatible_count,
"incompatible": incompatible_count,
},
}