Ctrl K
ComunidadGuías…

backend/chapters.py

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

Archivo original ↗Preguntar a KiBot
"""Chapter extraction and caching for MKV/MP4 video files.

Provides get_chapters(video_path) -> list of chapter dicts with millisecond timestamps.
Results are cached in chapter_cache DB table (invalidated by file mtime).
"""

import json
import logging
import subprocess
from datetime import UTC, datetime

logger = logging.getLogger(__name__)


def get_chapters(video_path: str) -> list[dict]:
    """Return chapter list for a video file.

    Checks chapter_cache first (mtime-validated). On miss, runs ffprobe and caches.
    Returns [] if the file has no chapters or ffprobe is unavailable.
    """
    import os

    try:
        mtime = os.path.getmtime(video_path)
    except OSError:
        return []

    cached = _get_cached(video_path, mtime)
    if cached is not None:
        return cached

    chapters = _probe_chapters(video_path)
    _set_cached(video_path, mtime, chapters)
    return chapters


def _probe_chapters(video_path: str) -> list[dict]:
    """Run ffprobe -show_chapters and return normalized chapter list."""
    from security_utils import safe_subprocess_arg

    try:
        result = subprocess.run(
            [
                "ffprobe",
                "-v",
                "quiet",
                "-print_format",
                "json",
                "-show_chapters",
                safe_subprocess_arg(video_path),
            ],
            capture_output=True,
            text=True,
            timeout=30,
        )
        data = json.loads(result.stdout or "{}")
        return [_normalize_chapter(c) for c in data.get("chapters", [])]
    except Exception as exc:
        logger.debug("Chapter probe failed for %s: %s", video_path, exc)
        return []


def _normalize_chapter(raw: dict) -> dict:
    """Convert raw ffprobe chapter dict to {id, title, start_ms, end_ms}."""
    idx = raw["id"]
    title = raw.get("tags", {}).get("title") or f"Chapter {idx + 1}"
    return {
        "id": idx,
        "title": title,
        "start_ms": int(float(raw["start_time"]) * 1000),
        "end_ms": int(float(raw["end_time"]) * 1000),
    }


def _get_cached(file_path: str, mtime: float) -> list[dict] | None:
    """Return cached chapters if mtime matches, else None."""
    from db.models.core import ChapterCache
    from extensions import db

    row = db.session.get(ChapterCache, file_path)
    if row is None or row.mtime != mtime:
        return None
    return json.loads(row.chapters_json)


def _set_cached(file_path: str, mtime: float, chapters: list[dict]) -> None:
    """Write chapters to chapter_cache (upsert)."""
    from db.models.core import ChapterCache
    from extensions import db

    now = datetime.now(UTC)
    existing = db.session.get(ChapterCache, file_path)
    if existing:
        existing.mtime = mtime
        existing.chapters_json = json.dumps(chapters)
        existing.cached_at = now
    else:
        db.session.add(
            ChapterCache(
                file_path=file_path,
                mtime=mtime,
                chapters_json=json.dumps(chapters),
                cached_at=now,
            )
        )
    db.session.commit()