Ctrl K
ComunidadGuías…

backend/compat_checker.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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,
        },
    }