Ctrl K
ComunidadGuías…

backend/bazarr_data_mapper.py

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

Archivo original ↗Preguntar a KiBot
"""Bazarr data mapping, preview, and import application.

Transforms parsed Bazarr config and database data into Sublarr format.
Handles migration preview generation, config/profile/blacklist/history import,
provider setting mapping, and batch migration orchestration.
"""

import logging
import re

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Preview & Masking
# ---------------------------------------------------------------------------


def preview_migration(config_data: dict, db_data: dict, current: dict | None = None) -> dict:
    """Generate a human-readable preview of what the migration will import.

    Args:
        config_data: Parsed config dict (from parse_bazarr_config).
        db_data: Parsed DB dict (from migrate_bazarr_db).
        current: The config entries stored today, so each planned entry can
            show what it would overwrite. Secrets are masked on both sides.

    Returns:
        Dict with sections describing each import category.
    """
    current = current or {}
    preview = {
        "config_entries": [],
        "profiles": [],
        "blacklist_count": 0,
        "warnings": [],
    }

    # Collect warnings from both sources
    preview["warnings"].extend(config_data.get("warnings", []))
    preview["warnings"].extend(db_data.get("warnings", []))

    planned = planned_config_entries(config_data)
    for arr, reason in refused_arr_urls(planned).items():
        preview["warnings"].append(
            f"{arr}_url will not be imported: {reason}. Set it under Settings instead."
        )
    for key, value, source in planned:
        existing = str(current.get(key) or "")
        secret = _is_secret(key)
        preview["config_entries"].append(
            {
                "key": key,
                "value": _mask_preview(value) if secret else value,
                "current_value": (_mask_preview(existing) if secret else existing)
                if existing
                else "",
                "source": source,
            }
        )

    # Profiles from DB
    for p in db_data.get("profiles", []):
        lang_list = [lang.get("language", "?") for lang in p.get("languages", [])]
        preview["profiles"].append(
            {
                "name": p.get("name", "Unnamed"),
                "languages": lang_list,
            }
        )

    # Blacklist count
    preview["blacklist_count"] = len(db_data.get("blacklist", []))

    # Extended counts from deeper DB reading
    preview["shows_count"] = len(db_data.get("shows", []))
    preview["movies_count"] = len(db_data.get("movies", []))
    preview["history_count"] = len(db_data.get("history", []))

    return preview


def _mask_preview(val: str) -> str:
    """Mask a value for preview display."""
    if not val or len(val) <= 4:
        return "***"
    return val[:4] + "***"


def _is_secret(key: str) -> bool:
    return "api_key" in key or "password" in key


def _truthy(value) -> bool:
    return str(value).strip().lower() in ("true", "1", "yes", "on")


def arr_url(section: dict) -> str:
    """Join Bazarr's separate host/port/ssl/base_url fields into one URL.

    ``{"url": "10.0.0.5", "port": 8989, "ssl": False, "base_url": "/sonarr"}``
    becomes ``http://10.0.0.5:8989/sonarr``. A host that already carries a
    scheme is kept as given (only the port and path are added).
    """
    host = str(section.get("url") or "").strip().rstrip("/")
    if not host:
        return ""
    if "://" in host:
        scheme, host = host.split("://", 1)
    else:
        scheme = "https" if _truthy(section.get("ssl")) else "http"
    if host.count(":") > 1 and not host.startswith("["):
        host = f"[{host}]"  # a bare IPv6 literal needs brackets before a port
    port = str(section.get("port") or "").strip()
    # "sonarr:8989" or "[::1]:8989" already carry their port.
    if port and not re.search(r"(^[^:]*|\]):\d+$", host):
        host = f"{host}:{port}"
    path = str(section.get("base_url") or "").strip().strip("/")
    return f"{scheme}://{host}/{path}" if path else f"{scheme}://{host}"


def refused_arr_urls(planned: list[tuple[str, str, str]]) -> dict[str, str]:
    """{"sonarr": reason} for every planned *arr URL the SSRF guard refuses.

    The same check PUT /config applies; preview reports it, apply skips the
    URL and its API key.
    """
    from security_utils import validate_service_url

    refused = {}
    for key, value, _source in planned:
        if key.endswith("_url"):
            ok, reason = validate_service_url(value)
            if not ok:
                refused[key[: -len("_url")]] = reason
    return refused


def planned_config_entries(config_data: dict) -> list[tuple[str, str, str]]:
    """The (key, value, source) config entries an import would write.

    Preview and apply both read this list, so what the user confirms is what
    gets written.
    """
    entries: list[tuple[str, str, str]] = []
    for arr in ("sonarr", "radarr"):
        section = config_data.get(arr) or {}
        url = arr_url(section)
        api_key = str(section.get("api_key") or "").strip()
        if url and api_key:
            source = f"Bazarr config ({arr})"
            entries.append((f"{arr}_url", url, source))
            entries.append((f"{arr}_api_key", api_key, source))

    general = config_data.get("general") or {}
    for field in ("opensubtitles_api_key", "opensubtitles_username", "opensubtitles_password"):
        value = str(general.get(field) or "").strip()
        if value:
            entries.append((field, value, "Bazarr config (opensubtitles)"))
    return entries


# ---------------------------------------------------------------------------
# Apply Migration
# ---------------------------------------------------------------------------


def apply_migration(config_data: dict, db_data: dict) -> dict:
    """Apply the Bazarr migration, importing config, profiles, and blacklist.

    Uses lazy imports for database modules to avoid circular imports.

    Args:
        config_data: Parsed config dict (from parse_bazarr_config).
        db_data: Parsed DB dict (from migrate_bazarr_db).

    Returns:
        Dict with counts of imported items and any warnings.
    """
    from db.config import save_config_entry

    result = {
        "config_imported": 0,
        "profiles_imported": 0,
        "blacklist_imported": 0,
        "warnings": [],
        "saved_keys": [],
    }

    # Config entries — a URL goes through the same SSRF guard PUT /config
    # applies, and a refused URL takes its API key with it (half a connection
    # is worse than none: the key would sit next to the old URL).
    planned = planned_config_entries(config_data)
    refused = refused_arr_urls(planned)
    for arr, reason in refused.items():
        result["warnings"].append(
            f"{arr}_url not imported: {reason}. Set it under Settings instead."
        )
    for key, value, _source in planned:
        if key.split("_", 1)[0] in refused:
            continue
        save_config_entry(key, value)
        result["config_imported"] += 1
        result["saved_keys"].append(key)

    # Import language profiles from DB
    try:
        from db.profiles import create_language_profile

        for p in db_data.get("profiles", []):
            try:
                languages = p.get("languages", [])
                if not languages:
                    result["warnings"].append(f"Skipping profile '{p.get('name')}': no languages")
                    continue

                # Map Bazarr profile to Sublarr format
                target_langs = [lang.get("language", "en") for lang in languages]
                target_names = [lang.get("language", "Unknown") for lang in languages]

                create_language_profile(
                    name=p.get("name", "Imported from Bazarr"),
                    source_lang="en",
                    source_name="English",
                    target_langs=target_langs,
                    target_names=target_names,
                )
                result["profiles_imported"] += 1
            except Exception as exc:
                result["warnings"].append(f"Failed to import profile '{p.get('name')}': {exc}")
    except ImportError as exc:
        result["warnings"].append(f"Profile import unavailable: {exc}")

    # Import blacklist entries from DB
    try:
        from db.repositories import add_blacklist_entry

        for entry in db_data.get("blacklist", []):
            try:
                add_blacklist_entry(
                    provider_name=entry.get("provider", "unknown"),
                    subtitle_id=entry.get("subtitle_id", ""),
                    language=entry.get("language", ""),
                )
                result["blacklist_imported"] += 1
            except Exception as exc:
                result["warnings"].append(f"Failed to import blacklist entry: {exc}")
    except ImportError as exc:
        result["warnings"].append(f"Blacklist import unavailable: {exc}")

    # Reload settings with new config values
    try:
        from config import reload_settings
        from db.config import get_all_config_entries

        all_overrides = get_all_config_entries()
        reload_settings(all_overrides)
    except Exception as exc:
        result["warnings"].append(f"Settings reload failed: {exc}")

    return result


# ---------------------------------------------------------------------------
# History Import
# ---------------------------------------------------------------------------


def import_bazarr_history(db_data: dict) -> dict:
    """Import Bazarr download history.

    Args:
        db_data: Parsed DB dict from migrate_bazarr_db

    Returns:
        Dict with counts of imported history entries
    """
    result = {
        "history_imported": 0,
        "warnings": [],
    }

    try:
        from db.repositories import add_history_entry

        history_entries = db_data.get("history", [])
        for entry in history_entries:
            try:
                # Map Bazarr history to Sublarr format
                add_history_entry(
                    file_path=entry.get("video_path", ""),
                    provider_name=entry.get("provider", "unknown"),
                    subtitle_id=entry.get("subs_id", ""),
                    language=entry.get("language", ""),
                    score=entry.get("score", 0),
                    downloaded_at=entry.get("timestamp", ""),
                )
                result["history_imported"] += 1
            except Exception as exc:
                result["warnings"].append(f"Failed to import history entry: {exc}")

    except ImportError as exc:
        result["warnings"].append(f"History import unavailable: {exc}")

    return result


# ---------------------------------------------------------------------------
# Provider Settings Mapping
# ---------------------------------------------------------------------------

# Bazarr provider name -> Sublarr provider name mapping
_PROVIDER_MAP = {
    "opensubtitles": "OpenSubtitles",
    "addic7ed": "Addic7ed",
    "podnapisi": "Podnapisi",
    "legendasdivx": "LegendasDivx",
    "subscenter": "SubsCenter",
    "thesubdb": "TheSubDB",
    "tvsubtitles": "TVSubtitles",
}


def map_bazarr_provider_settings(config_data: dict) -> dict:
    """Map Bazarr provider settings to Sublarr provider configuration.

    Args:
        config_data: Parsed config dict from parse_bazarr_config

    Returns:
        Dict with provider mappings and settings
    """
    result = {
        "provider_mappings": {},
        "settings_imported": 0,
        "warnings": [],
    }

    general = config_data.get("general", {})

    # Map OpenSubtitles settings
    if general.get("opensubtitles_api_key"):
        result["provider_mappings"]["OpenSubtitles"] = {
            "api_key": general["opensubtitles_api_key"],
            "username": general.get("opensubtitles_username"),
            "password": general.get("opensubtitles_password"),
        }
        result["settings_imported"] += 1

    # Map other provider settings (if available in config)
    for bazarr_name, sublarr_name in _PROVIDER_MAP.items():
        if bazarr_name == "opensubtitles":
            continue  # Already handled

        # Check for provider-specific settings in config
        provider_key = f"{bazarr_name}_api_key"
        if general.get(provider_key):
            result["provider_mappings"][sublarr_name] = {
                "api_key": general[provider_key],
            }
            result["settings_imported"] += 1

    return result


# ---------------------------------------------------------------------------
# Batch Migration
# ---------------------------------------------------------------------------


def batch_migrate_bazarr_instances(instances: list[dict]) -> dict:
    """Batch migrate multiple Bazarr instances.

    Args:
        instances: List of dicts with "config_path" and/or "db_path" keys

    Returns:
        Dict with migration results for each instance
    """
    from bazarr_migrator import migrate_bazarr_db, parse_bazarr_config

    results = {
        "total": len(instances),
        "successful": 0,
        "failed": 0,
        "instances": [],
    }

    for i, instance in enumerate(instances):
        instance_result = {
            "index": i,
            "config_path": instance.get("config_path"),
            "db_path": instance.get("db_path"),
            "status": "pending",
            "error": None,
            "imported": {},
        }

        try:
            # Parse config if provided
            config_data = {}
            if instance.get("config_path"):
                with open(instance["config_path"], encoding="utf-8") as f:
                    config_content = f.read()
                config_data = parse_bazarr_config(config_content, instance["config_path"])

            # Parse DB if provided
            db_data = {}
            if instance.get("db_path"):
                db_data = migrate_bazarr_db(instance["db_path"])

            # Apply migration
            migration_result = apply_migration(config_data, db_data)

            # Import history
            if db_data:
                history_result = import_bazarr_history(db_data)
                migration_result.update(history_result)

            # Map provider settings
            provider_result = map_bazarr_provider_settings(config_data)
            migration_result["provider_mappings"] = provider_result["provider_mappings"]

            instance_result["status"] = "success"
            instance_result["imported"] = migration_result
            results["successful"] += 1

        except Exception as e:
            instance_result["status"] = "failed"
            instance_result["error"] = str(e)
            results["failed"] += 1
            logger.exception("Batch migration failed for instance %d", i)

        results["instances"].append(instance_result)

    return results