diff --git a/Dockerfile b/Dockerfile index 47e2ad9d..8cc165db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -64,8 +64,9 @@ ARG GIT_SHA=unknown # because it carries the DV RPU-aware tonemap_opencl patches upstream lacks). RUN apt-get update && \ apt-get install -y --no-install-recommends \ - mediainfo python3 python3-pip gosu pciutils git curl gnupg ca-certificates \ - mesa-va-drivers mesa-vulkan-drivers libva2 libva-drm2 vainfo && \ + mediainfo python3 python3-pip gosu pciutils git curl gnupg ca-certificates \ + libchromaprint-tools \ + mesa-va-drivers mesa-vulkan-drivers libva2 libva-drm2 vainfo && \ if [ "$(dpkg --print-architecture)" = "amd64" ]; then \ # Jellyfin apt repo (Ubuntu Noble) for jellyfin-ffmpeg7 curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key \ diff --git a/README.md b/README.md index eadb27b6..6fb59038 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ Generates video preview thumbnails (BIF files) for Plex Media Server. These are | **Web Dashboard** | Manage jobs, schedules, and status | | **Scheduling** | Cron and interval-based automation | | **Smart Skipping** | Automatically skips files that already have thumbnails | +| **Credits Detection** | Automatic "Skip Credits" markers for movies and episodes | +| **Intro Detection** | Cross-episode audio fingerprinting finds recurring intros | | **Radarr/Sonarr** | Webhook integration for auto-processing on import | | **Plex direct webhook** | Auto-trigger on `library.new` (Plex Pass) for media added without Sonarr/Radarr | | **Recently Added scanner** | Polling fallback that catches manually-added items without Plex Pass | diff --git a/docs/guides.md b/docs/guides.md index da9cede7..2517d903 100644 --- a/docs/guides.md +++ b/docs/guides.md @@ -458,6 +458,67 @@ You can enable **both** if you want belt-and-suspenders behavior — the recentl --- +## Load Testing + +A Locust load test is available for stress testing the web API. + +### Running Load Tests + +```bash +# Interactive mode (opens browser UI) +locust -f tests/load/locustfile.py + +# Open http://localhost:8089 to configure and start +``` + +```bash +# Headless mode +locust -f tests/load/locustfile.py --headless -u 50 -r 10 -t 60s +``` + +> [!NOTE] +> Locust is a dev dependency. Install with `pip install -e ".[dev]"`. + +--- + +## Marker Detection (Intro & Credits) + +### Enabling Detection + +1. Go to **Settings** in the web UI +2. Scroll to the **Credits Detection** and/or **Intro Detection** cards +3. Toggle the enable switch +4. Adjust parameters if needed (defaults work well for most content) +5. Click **Save Changes** +6. Run a job — detection runs automatically alongside BIF generation + +### How It Works + +**Credits detection** scans the last portion of each video with FFmpeg's `blackdetect` and `silencedetect` filters. When it finds a black frame + silence region near the end, it writes a "Skip Credits" marker to the Plex database. + +**Intro detection** uses a two-pass approach: +1. **Pass 1** (parallel): Each episode gets its first ~10 minutes fingerprinted using `fpcalc` (chromaprint audio fingerprinting) +2. **Pass 2** (after all episodes): Fingerprints are compared across episodes in each season to find the recurring intro theme + +### Troubleshooting + +**Markers not appearing in Plex clients:** +- Restart your Plex client (or exit and re-enter the media). Plex clients cache marker data. +- Verify markers were written: check the job logs for "Credits marker:" or "Intro found for" messages. + +**"Database locked" warnings:** +- Normal when Plex is busy (scanning, analyzing). The tool retries automatically (5-second timeout). +- If persistent, avoid running detection during Plex library scans. + +**Docker on Windows:** +- Windows Docker volume mounts do not support SQLite file locking. Stop Plex Media Server before running detection, or use a Linux/macOS host. +- The Settings page shows a warning when this environment is detected. + +**"fpcalc not found" for intro detection:** +- The Docker image includes `fpcalc`. If running from source, install `libchromaprint-tools`. + +--- + ## HDR & Dolby Vision The tool auto-detects HDR metadata and tone-maps to SDR before generating thumbnails. Behavior depends on the HDR format: diff --git a/docs/reference.md b/docs/reference.md index 68c1d191..48444949 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -85,6 +85,44 @@ GPU settings are configured per-GPU in **Settings** → **Processing Options**. > No separate fallback pool is needed — increase `cpu_threads` if you > want more dedicated CPU concurrency for files that never hit the GPU. +### Marker Detection (Experimental) + +Detects intro and credits segments in media files and writes "Skip Intro" / "Skip Credits" markers directly to the Plex SQLite database. Markers appear as skip buttons in Plex clients. + +#### Credits Detection + +Analyzes the end of each video using FFmpeg's `blackdetect` and `silencedetect` filters to find where credits begin. Works on both movies and TV episodes. + +| Setting | Web UI | Default | Description | +|---------|--------|---------|-------------| +| `credits_detection_enabled` | Yes | `false` | Enable credits detection | +| `credits_detection_overwrite` | Yes | `false` | Re-detect even if markers exist | +| `credits_scan_last_pct` | Yes | `25` | Scan last N% of video (5–50) | +| `credits_min_duration` | Yes | `15` | Minimum credits length in seconds (5–120) | + +#### Intro Detection + +Compares audio fingerprints across all episodes in a TV season to find the recurring intro theme. Uses `fpcalc` (chromaprint) for fingerprinting. Requires at least 2 episodes per season. + +| Setting | Web UI | Default | Description | +|---------|--------|---------|-------------| +| `intro_detection_enabled` | Yes | `false` | Enable intro detection | +| `intro_detection_overwrite` | Yes | `false` | Re-detect even if markers exist | +| `intro_scan_duration_sec` | Yes | `600` | Seconds of audio to fingerprint (first N seconds of each episode) | +| `intro_min_duration_sec` | Yes | `15` | Minimum intro length in seconds | +| `intro_max_duration_sec` | Yes | `120` | Maximum intro length in seconds | + +> [!WARNING] +> Marker detection writes directly to the Plex SQLite database (`com.plexapp.plugins.library.db`). +> Back up your database before enabling. Detection is safe while Plex is running (Plex uses WAL mode), +> but **Docker on Windows** volume mounts do not support SQLite file locking — stop Plex before +> running detection on Windows, or use a Linux/macOS host. + +> [!NOTE] +> The Plex REST API only supports bookmark-type markers. Intro and credits markers must be written +> directly to the database. This is the same approach used by [MarkerEditorForPlex](https://github.com/danrahn/MarkerEditorForPlex) +> and other community tools. + --- ## Environment Variables diff --git a/plex_generate_previews/config/__init__.py b/plex_generate_previews/config/__init__.py index 987fa8cb..b39172d9 100644 --- a/plex_generate_previews/config/__init__.py +++ b/plex_generate_previews/config/__init__.py @@ -175,6 +175,19 @@ class Config: # Exclude paths: list of {"value": str, "type": "path"|"regex"}; path = prefix match, regex = full match exclude_paths: list[dict[str, str]] | None = None + # Credits detection + credits_detection_enabled: bool = False + credits_detection_overwrite: bool = False + credits_scan_last_pct: float = 25.0 + credits_min_duration: float = 15.0 + + # Intro detection + intro_detection_enabled: bool = False + intro_detection_overwrite: bool = False + intro_scan_duration_sec: float = 600.0 # first 10 minutes + intro_min_duration_sec: float = 15.0 + intro_max_duration_sec: float = 120.0 + def __repr__(self) -> str: """Return a string representation with plex_token redacted.""" fields = [] diff --git a/plex_generate_previews/credits_detection.py b/plex_generate_previews/credits_detection.py new file mode 100644 index 00000000..64a1128c --- /dev/null +++ b/plex_generate_previews/credits_detection.py @@ -0,0 +1,427 @@ +"""Credits detection using FFmpeg blackdetect and silencedetect filters. + +Analyzes the last portion of a video file to identify where credits begin +by combining black frame detection and silence detection. The earliest +qualifying combined region is reported as the credits start point. + +No external dependencies beyond FFmpeg are required. +""" + +import re +import subprocess +from collections.abc import Callable +from dataclasses import dataclass + +from loguru import logger + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + + +@dataclass +class BlackFrame: + """A detected black frame region.""" + + start: float # seconds + end: float # seconds + duration: float # seconds + + +@dataclass +class SilenceRegion: + """A detected silence region.""" + + start: float # seconds + end: float # seconds + duration: float # seconds + + +@dataclass +class CreditsSegment: + """A detected credits segment.""" + + start_ms: int # milliseconds + end_ms: int # milliseconds + confidence: float # 0.0 to 1.0 + method: str # "black+silence", "black_only", "silence_only" + + +@dataclass +class CreditsDetectionConfig: + """Tunable parameters for credits detection.""" + + enabled: bool = False + scan_last_pct: float = 25.0 # Scan last N% of video + black_min_duration: float = 0.5 # Min black frame duration (seconds) + black_pix_threshold: float = 0.10 # Pixel threshold for black detection + silence_noise_threshold: str = "-40dB" # Noise floor for silence + silence_min_duration: float = 3.0 # Min silence duration (seconds) + min_credits_duration: float = 15.0 # Min credits length to accept (seconds) + max_credits_start_pct: float = 75.0 # Credits must start after this % of video + + +# --------------------------------------------------------------------------- +# FFmpeg filter runners +# --------------------------------------------------------------------------- + +_BLACK_RE = re.compile( + r"black_start:(\d+(?:\.\d+)?)\s+" + r"black_end:(\d+(?:\.\d+)?)\s+" + r"black_duration:(\d+(?:\.\d+)?)" +) + +_SILENCE_START_RE = re.compile(r"silence_start:\s*(\d+(?:\.\d+)?)") +_SILENCE_END_RE = re.compile(r"silence_end:\s*(\d+(?:\.\d+)?)\s*\|\s*silence_duration:\s*(\d+(?:\.\d+)?)") + + +def _run_blackdetect( + media_file: str, + seek_to: float, + ffmpeg_path: str = "ffmpeg", + black_min_duration: float = 0.5, + pix_threshold: float = 0.10, + cancel_check: Callable | None = None, +) -> list[BlackFrame]: + """Run FFmpeg blackdetect filter and parse output. + + Args: + media_file: Path to the video file. + seek_to: Start scanning from this timestamp (seconds). + ffmpeg_path: Path to the ffmpeg binary. + black_min_duration: Minimum black frame duration in seconds. + pix_threshold: Pixel brightness threshold (0.0–1.0). + cancel_check: Optional callable for cancellation. + + Returns: + List of detected black frame regions. + + """ + cmd = [ + ffmpeg_path, + "-ss", + str(seek_to), + "-i", + media_file, + "-vf", + f"blackdetect=d={black_min_duration}:pix_th={pix_threshold}", + "-an", + "-f", + "null", + "-", + ] + + frames: list[BlackFrame] = [] + try: + proc = subprocess.Popen( + cmd, + stderr=subprocess.PIPE, + stdout=subprocess.DEVNULL, + text=True, + ) + for line in proc.stderr: + if cancel_check and cancel_check(): + proc.kill() + break + match = _BLACK_RE.search(line) + if match: + # Timestamps are relative to the seek point; add offset + start = float(match.group(1)) + seek_to + end = float(match.group(2)) + seek_to + duration = float(match.group(3)) + frames.append(BlackFrame(start=start, end=end, duration=duration)) + proc.wait() + except FileNotFoundError: + logger.warning(f"ffmpeg not found at {ffmpeg_path}") + except Exception as exc: + logger.warning(f"blackdetect failed: {exc}") + + return frames + + +def _run_silencedetect( + media_file: str, + seek_to: float, + ffmpeg_path: str = "ffmpeg", + noise_threshold: str = "-40dB", + silence_duration: float = 3.0, + cancel_check: Callable | None = None, +) -> list[SilenceRegion]: + """Run FFmpeg silencedetect filter and parse output. + + Args: + media_file: Path to the video file. + seek_to: Start scanning from this timestamp (seconds). + ffmpeg_path: Path to the ffmpeg binary. + noise_threshold: Noise floor (e.g. ``"-40dB"``). + silence_duration: Minimum silence duration in seconds. + cancel_check: Optional callable for cancellation. + + Returns: + List of detected silence regions. + + """ + cmd = [ + ffmpeg_path, + "-ss", + str(seek_to), + "-i", + media_file, + "-af", + f"silencedetect=n={noise_threshold}:d={silence_duration}", + "-vn", + "-f", + "null", + "-", + ] + + regions: list[SilenceRegion] = [] + pending_start: float | None = None + try: + proc = subprocess.Popen( + cmd, + stderr=subprocess.PIPE, + stdout=subprocess.DEVNULL, + text=True, + ) + for line in proc.stderr: + if cancel_check and cancel_check(): + proc.kill() + break + + start_match = _SILENCE_START_RE.search(line) + if start_match: + pending_start = float(start_match.group(1)) + seek_to + + end_match = _SILENCE_END_RE.search(line) + if end_match and pending_start is not None: + end = float(end_match.group(1)) + seek_to + duration = float(end_match.group(2)) + regions.append(SilenceRegion(start=pending_start, end=end, duration=duration)) + pending_start = None + proc.wait() + except FileNotFoundError: + logger.warning(f"ffmpeg not found at {ffmpeg_path}") + except Exception as exc: + logger.warning(f"silencedetect failed: {exc}") + + return regions + + +# --------------------------------------------------------------------------- +# Combination logic +# --------------------------------------------------------------------------- + + +def _regions_overlap_or_adjacent( + a_start: float, + a_end: float, + b_start: float, + b_end: float, + tolerance: float = 2.0, +) -> bool: + """Check if two time regions overlap or are within *tolerance* seconds.""" + return a_start <= b_end + tolerance and b_start <= a_end + tolerance + + +def _find_black_cluster( + black_frames: list[BlackFrame], + min_start_time: float, + min_frames: int = 2, + cluster_window: float = 60.0, +) -> float | None: + """Find the earliest cluster of multiple black frames. + + A single black frame can be a scene transition; credits typically + produce several black frames within a short window. + + Args: + black_frames: Detected black frame regions (sorted by start). + min_start_time: Ignore frames before this timestamp. + min_frames: Minimum black frames in a cluster. + cluster_window: Max seconds between first and last frame in cluster. + + Returns: + Start time of the first frame in the cluster, or None. + + """ + eligible = [bf for bf in black_frames if bf.start >= min_start_time] + for i, anchor in enumerate(eligible): + cluster = [anchor] + for later in eligible[i + 1 :]: + if later.start - anchor.start <= cluster_window: + cluster.append(later) + else: + break + if len(cluster) >= min_frames: + return cluster[0].start + return None + + +def _combine_detections( + black_frames: list[BlackFrame], + silence_regions: list[SilenceRegion], + total_duration_sec: float, + min_credits_duration_sec: float = 15.0, + max_credits_start_pct: float = 75.0, +) -> CreditsSegment | None: + """Combine black frame and silence detections to identify credits. + + Strategy (ordered by confidence): + 1. Black+silence overlap — a black frame cluster coincides with silence. + 2. Black cluster only — multiple black frames within 60s (not a single + scene-transition frame, which caused false positives). + 3. Silence only — long silence near the end (lowest confidence). + + Args: + black_frames: Detected black frame regions. + silence_regions: Detected silence regions. + total_duration_sec: Total video duration in seconds. + min_credits_duration_sec: Minimum credits length in seconds. + max_credits_start_pct: Credits must start after this % of video. + + Returns: + CreditsSegment or None. + + """ + min_start_time = total_duration_sec * (max_credits_start_pct / 100.0) + end_ms = int(total_duration_sec * 1000) + sorted_blacks = sorted(black_frames, key=lambda f: f.start) + + # Strategy 1: Black frame cluster + silence overlap (highest confidence) + cluster_start = _find_black_cluster(sorted_blacks, min_start_time) + if cluster_start is not None: + for sr in silence_regions: + if _regions_overlap_or_adjacent(cluster_start, cluster_start + 60.0, sr.start, sr.end): + credits_start = min(cluster_start, sr.start) + credits_duration = total_duration_sec - credits_start + if credits_duration >= min_credits_duration_sec: + return CreditsSegment( + start_ms=int(credits_start * 1000), + end_ms=end_ms, + confidence=0.9, + method="black+silence", + ) + + # Strategy 2: Single black + silence overlap (good confidence) + for bf in sorted_blacks: + if bf.start < min_start_time: + continue + for sr in silence_regions: + if _regions_overlap_or_adjacent(bf.start, bf.end, sr.start, sr.end): + credits_start = min(bf.start, sr.start) + credits_duration = total_duration_sec - credits_start + if credits_duration >= min_credits_duration_sec: + return CreditsSegment( + start_ms=int(credits_start * 1000), + end_ms=end_ms, + confidence=0.8, + method="black+silence", + ) + + # Strategy 3: Black cluster only — requires 2+ frames to avoid + # false positives from single scene-transition black frames. + if cluster_start is not None: + credits_duration = total_duration_sec - cluster_start + if credits_duration >= min_credits_duration_sec: + return CreditsSegment( + start_ms=int(cluster_start * 1000), + end_ms=end_ms, + confidence=0.6, + method="black_cluster", + ) + + # Strategy 4: Silence only (low confidence — below the 0.5 write + # threshold, so only written if the user lowers the threshold) + for sr in sorted(silence_regions, key=lambda r: r.start): + if sr.start < min_start_time: + continue + credits_duration = total_duration_sec - sr.start + if credits_duration >= min_credits_duration_sec: + return CreditsSegment( + start_ms=int(sr.start * 1000), + end_ms=end_ms, + confidence=0.4, + method="silence_only", + ) + + return None + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def detect_credits( + media_file: str, + total_duration_sec: float, + ffmpeg_path: str = "ffmpeg", + config: CreditsDetectionConfig | None = None, + cancel_check: Callable | None = None, +) -> CreditsSegment | None: + """Detect the credits start point in a media file. + + Runs FFmpeg blackdetect and silencedetect filters on the last + portion of the video and combines results. + + Args: + media_file: Path to the video file. + total_duration_sec: Total video duration in seconds. + ffmpeg_path: Path to the ffmpeg binary. + config: Detection parameters (uses defaults if None). + cancel_check: Optional callable for cancellation. + + Returns: + CreditsSegment if credits were detected, None otherwise. + + """ + if config is None: + config = CreditsDetectionConfig() + + if total_duration_sec < config.min_credits_duration: + logger.debug( + f"Video too short for credits detection ({total_duration_sec:.0f}s < {config.min_credits_duration}s)" + ) + return None + + # Scan the last N% of the video + seek_to = total_duration_sec * (1.0 - config.scan_last_pct / 100.0) + + logger.info( + f"Credits scan: scanning from {seek_to:.0f}s (last {config.scan_last_pct}% of {total_duration_sec:.0f}s)" + ) + + black_frames = _run_blackdetect( + media_file, + seek_to, + ffmpeg_path, + black_min_duration=config.black_min_duration, + pix_threshold=config.black_pix_threshold, + cancel_check=cancel_check, + ) + + if cancel_check and cancel_check(): + return None + + silence_regions = _run_silencedetect( + media_file, + seek_to, + ffmpeg_path, + noise_threshold=config.silence_noise_threshold, + silence_duration=config.silence_min_duration, + cancel_check=cancel_check, + ) + + if cancel_check and cancel_check(): + return None + + logger.debug(f"Credits detection: found {len(black_frames)} black frames, {len(silence_regions)} silence regions") + + return _combine_detections( + black_frames, + silence_regions, + total_duration_sec, + min_credits_duration_sec=config.min_credits_duration, + max_credits_start_pct=config.max_credits_start_pct, + ) diff --git a/plex_generate_previews/intro_detection.py b/plex_generate_previews/intro_detection.py new file mode 100644 index 00000000..6c32eeab --- /dev/null +++ b/plex_generate_previews/intro_detection.py @@ -0,0 +1,386 @@ +"""Intro detection using chromaprint audio fingerprinting. + +Generates audio fingerprints for TV episodes using ``fpcalc`` (from +``libchromaprint-tools``), then compares fingerprints across episodes +in a season to find the recurring intro segment. + +Two-phase design: +- **Phase 1** (parallel, per-episode): ``fingerprint_episode()`` generates + a fingerprint for the first N minutes and stores it in an + ``IntroFingerprintStore``. +- **Phase 2** (sequential, post-dispatch): ``find_common_intro()`` + compares fingerprints within each season to identify the common intro. +""" + +import shutil +import subprocess +import threading +from collections.abc import Callable +from dataclasses import dataclass + +from loguru import logger + +# Chromaprint samples at ~8000 fingerprint integers per minute of audio +# at the default sample rate. Each integer covers ~0.1238 seconds. +_FPCALC_ITEM_DURATION_SEC = 0.1238 + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + + +@dataclass +class MatchRegion: + """A region of matching audio between two fingerprints.""" + + offset_a: int # index in fingerprint A + offset_b: int # index in fingerprint B + length: int # number of matching items + score: float # average match quality (0.0–1.0) + + @property + def duration_sec(self) -> float: + return self.length * _FPCALC_ITEM_DURATION_SEC + + @property + def start_sec_a(self) -> float: + return self.offset_a * _FPCALC_ITEM_DURATION_SEC + + @property + def start_sec_b(self) -> float: + return self.offset_b * _FPCALC_ITEM_DURATION_SEC + + +@dataclass +class IntroSegment: + """A detected intro segment.""" + + start_ms: int + end_ms: int + confidence: float # 0.0 to 1.0 + + +# --------------------------------------------------------------------------- +# Fingerprint store (thread-safe, used during parallel pass 1) +# --------------------------------------------------------------------------- + + +class IntroFingerprintStore: + """Thread-safe store for episode fingerprints grouped by season. + + Collects fingerprints during the parallel processing pass and + provides grouped access for the comparison pass. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + # Key: (show_title, season_number) → [(rating_key, fingerprint)] + self._data: dict[tuple[str, int], list[tuple[int, list[int]]]] = {} + + def add( + self, + show_title: str, + season_number: int, + rating_key: int, + fingerprint: list[int], + ) -> None: + """Add a fingerprint for an episode.""" + key = (show_title, season_number) + with self._lock: + if key not in self._data: + self._data[key] = [] + self._data[key].append((rating_key, fingerprint)) + + def get_seasons(self) -> list[tuple[str, int, list[tuple[int, list[int]]]]]: + """Return all seasons with 2+ fingerprinted episodes. + + Returns: + List of (show_title, season_number, [(rating_key, fingerprint)]) + tuples, sorted by show title then season number. + + """ + with self._lock: + results = [] + for (show, season), episodes in sorted(self._data.items()): + if len(episodes) >= 2: + results.append((show, season, list(episodes))) + return results + + def __bool__(self) -> bool: + with self._lock: + return bool(self._data) + + def __len__(self) -> int: + with self._lock: + return sum(len(eps) for eps in self._data.values()) + + +# --------------------------------------------------------------------------- +# fpcalc runner +# --------------------------------------------------------------------------- + + +def check_fpcalc_available() -> bool: + """Check whether ``fpcalc`` is installed and reachable.""" + return shutil.which("fpcalc") is not None + + +def _run_fpcalc( + media_file: str, + length_sec: float, +) -> list[int] | None: + """Run ``fpcalc -raw`` and parse the integer fingerprint array. + + Args: + media_file: Path to the audio/video file. + length_sec: Duration to analyze in seconds. + + Returns: + List of 32-bit fingerprint integers, or None on failure. + + """ + cmd = [ + "fpcalc", + "-raw", + "-length", + str(int(length_sec)), + media_file, + ] + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=120, + ) + if result.returncode != 0: + logger.debug(f"fpcalc returned {result.returncode}: {result.stderr.strip()}") + return None + + for line in result.stdout.splitlines(): + if line.startswith("FINGERPRINT="): + raw = line[len("FINGERPRINT=") :] + return [int(x) for x in raw.split(",") if x.strip()] + + logger.debug("fpcalc output did not contain FINGERPRINT line") + return None + except FileNotFoundError: + logger.warning("fpcalc not found — install libchromaprint-tools") + return None + except subprocess.TimeoutExpired: + logger.warning(f"fpcalc timed out on {media_file}") + return None + except Exception as exc: + logger.warning(f"fpcalc failed on {media_file}: {exc}") + return None + + +def fingerprint_episode( + media_file: str, + duration_limit_sec: float = 600.0, + cancel_check: Callable | None = None, +) -> list[int] | None: + """Generate a chromaprint fingerprint for the first N seconds. + + Args: + media_file: Path to the episode file. + duration_limit_sec: How many seconds to analyze (default 10 min). + cancel_check: Optional callable for cancellation. + + Returns: + List of fingerprint integers, or None on failure. + + """ + if cancel_check and cancel_check(): + return None + + return _run_fpcalc(media_file, duration_limit_sec) + + +# --------------------------------------------------------------------------- +# Fingerprint comparison +# --------------------------------------------------------------------------- + + +def _popcount(x: int) -> int: + """Count the number of set bits in a 32-bit integer.""" + return bin(x & 0xFFFFFFFF).count("1") + + +def _compare_fingerprints( + fp1: list[int], + fp2: list[int], + max_offset: int = 200, + match_threshold: int = 8, + min_run_length: int = 50, +) -> list[MatchRegion]: + """Compare two fingerprints using sliding window Hamming distance. + + Slides fp2 over fp1 at various offsets and finds runs of matching + items (where Hamming distance ≤ threshold). + + Complexity: O(max_offset * overlap_length) per pair — pure Python. + Acceptable for v1; can be optimized with numpy or Cython if needed + for very large seasons. + + Args: + fp1: First fingerprint array. + fp2: Second fingerprint array. + max_offset: Maximum offset to try in each direction. + match_threshold: Max Hamming distance to consider a match (0–32). + min_run_length: Minimum consecutive matches to report. + + Returns: + List of MatchRegion objects. + + """ + regions: list[MatchRegion] = [] + + for offset in range(-max_offset, max_offset + 1): + # Determine the overlapping range + if offset >= 0: + start_a, start_b = offset, 0 + else: + start_a, start_b = 0, -offset + + overlap_len = min(len(fp1) - start_a, len(fp2) - start_b) + if overlap_len < min_run_length: + continue + + # Count consecutive matches + run_start = None + run_scores: list[float] = [] + + for i in range(overlap_len): + hamming = _popcount(fp1[start_a + i] ^ fp2[start_b + i]) + if hamming <= match_threshold: + if run_start is None: + run_start = i + run_scores = [] + run_scores.append(1.0 - hamming / 32.0) + else: + if run_start is not None and len(run_scores) >= min_run_length: + regions.append( + MatchRegion( + offset_a=start_a + run_start, + offset_b=start_b + run_start, + length=len(run_scores), + score=sum(run_scores) / len(run_scores), + ) + ) + run_start = None + run_scores = [] + + # Final run at end of overlap + if run_start is not None and len(run_scores) >= min_run_length: + regions.append( + MatchRegion( + offset_a=start_a + run_start, + offset_b=start_b + run_start, + length=len(run_scores), + score=sum(run_scores) / len(run_scores), + ) + ) + + return regions + + +def _find_best_common_segment( + all_matches: list[MatchRegion], + min_duration_sec: float = 15.0, + max_duration_sec: float = 120.0, +) -> IntroSegment | None: + """Find the best intro segment from pairwise match results. + + Picks the longest high-scoring match that falls within the + duration constraints. + + Args: + all_matches: Match regions from pairwise comparisons. + min_duration_sec: Minimum intro length. + max_duration_sec: Maximum intro length. + + Returns: + IntroSegment or None. + + """ + # Filter by duration and sort by score * length (quality) + candidates = [] + for m in all_matches: + duration = m.duration_sec + if min_duration_sec <= duration <= max_duration_sec: + candidates.append(m) + + if not candidates: + return None + + # Pick the best candidate by score * duration + best = max(candidates, key=lambda m: m.score * m.duration_sec) + start_ms = int(best.start_sec_a * 1000) + end_ms = int((best.start_sec_a + best.duration_sec) * 1000) + + return IntroSegment( + start_ms=start_ms, + end_ms=end_ms, + confidence=best.score, + ) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def find_common_intro( + fingerprints: list[tuple[int, list[int]]], + min_duration_sec: float = 15.0, + max_duration_sec: float = 120.0, +) -> IntroSegment | None: + """Compare fingerprints across episodes to find the common intro. + + Performs pairwise comparison of all fingerprints and identifies + the recurring audio segment. + + Args: + fingerprints: List of ``(rating_key, fingerprint)`` tuples. + min_duration_sec: Minimum intro length in seconds. + max_duration_sec: Maximum intro length in seconds. + + Returns: + IntroSegment if a common intro is found, None otherwise. + + """ + if len(fingerprints) < 2: + return None + + # Multi-reference comparison: compare several pairs instead of just + # episode[0] vs all others. This avoids false negatives when the + # first episode is a pilot/special with a different intro. + # For N episodes, compare pairs: (0,1), (0,2), (1,2), ... up to a + # reasonable limit, then vote on the best segment. + n = len(fingerprints) + pairs = [] + # Compare first 3 episodes pairwise (up to 3 pairs) + for i in range(min(n, 3)): + for j in range(i + 1, min(n, 3)): + pairs.append((i, j)) + # Also compare episode 0 vs later episodes for broader coverage + for j in range(3, min(n, 6)): + pairs.append((0, j)) + + all_matches: list[MatchRegion] = [] + for i, j in pairs: + _, fp_i = fingerprints[i] + _, fp_j = fingerprints[j] + matches = _compare_fingerprints(fp_i, fp_j) + all_matches.extend(matches) + + if not all_matches: + logger.info("No matching audio segments found across episodes") + return None + + result = _find_best_common_segment(all_matches, min_duration_sec, max_duration_sec) + if result: + logger.info(f"Found common intro: {result.start_ms}ms–{result.end_ms}ms (confidence: {result.confidence:.0%})") + return result diff --git a/plex_generate_previews/jobs/dispatcher.py b/plex_generate_previews/jobs/dispatcher.py index 4737e187..11604cfd 100644 --- a/plex_generate_previews/jobs/dispatcher.py +++ b/plex_generate_previews/jobs/dispatcher.py @@ -85,6 +85,7 @@ def __init__( self.on_item_complete: Callable | None = cbs.get("on_item_complete") self.cancel_check: Callable | None = cbs.get("cancel_check") self.pause_check: Callable | None = cbs.get("pause_check") + self.fingerprint_store = cbs.get("fingerprint_store") # Throttle timestamps for callbacks self._last_progress_update = 0.0 @@ -426,6 +427,7 @@ def _assign_tasks(self) -> None: job_id=job_id, library_name=library_name, cancel_check=tracker.cancel_check, + fingerprint_store=tracker.fingerprint_store, ) logger.info(f"Dispatch: assigned {media_title!r} (job {job_id[:8]}) to {worker.display_name}") diff --git a/plex_generate_previews/jobs/orchestrator.py b/plex_generate_previews/jobs/orchestrator.py index aafd885c..7a03ea74 100644 --- a/plex_generate_previews/jobs/orchestrator.py +++ b/plex_generate_previews/jobs/orchestrator.py @@ -91,6 +91,14 @@ def _merge_outcome(result_dict): logger.info("Running in headless mode (no console display)") + # Create fingerprint store for intro detection pass 1. + # Passed explicitly through the worker pipeline (not attached to config). + fingerprint_store = None + if getattr(config, "intro_detection_enabled", False): + from ..intro_detection import IntroFingerprintStore + + fingerprint_store = IntroFingerprintStore() + _dispatch_started = False def _dispatch_items(items, library_name): @@ -130,6 +138,7 @@ def _dispatch_items(items, library_name): "on_item_complete": item_complete_callback, "cancel_check": cancel_check, "pause_check": pause_check, + "fingerprint_store": fingerprint_store, } from ..web.jobs import PRIORITY_NORMAL @@ -233,6 +242,17 @@ def _dispatch_items(items, library_name): cancellation_requested = cancellation_requested or result["cancelled"] _merge_outcome(result) + # Intro detection pass 2: compare fingerprints within each season + if fingerprint_store and not cancellation_requested: + if progress_callback: + seasons = fingerprint_store.get_seasons() + progress_callback( + total_processed, + total_processed, + f"Intro detection: comparing {len(seasons)} season(s)...", + ) + _process_intro_fingerprints(fingerprint_store, config, plex, cancel_check, progress_callback) + generated = aggregate_outcome.get("generated", 0) bif_exists = aggregate_outcome.get("skipped_bif_exists", 0) not_found = aggregate_outcome.get("skipped_file_not_found", 0) @@ -307,3 +327,92 @@ def _dispatch_items(items, library_name): logger.debug(f"Cleaned up working temp folder: {config.working_tmp_folder}") except Exception as cleanup_error: logger.warning(f"Failed to clean up working temp folder {config.working_tmp_folder}: {cleanup_error}") + + +def _process_intro_fingerprints(fingerprint_store, config, plex, cancel_check=None, progress_callback=None): + """Intro detection pass 2: compare fingerprints and write markers. + + Iterates over each season with 2+ fingerprinted episodes, finds the + common intro segment, and writes intro markers for all episodes. + + Args: + fingerprint_store: IntroFingerprintStore with pass-1 data. + config: Configuration object. + plex: Plex server instance. + cancel_check: Optional cancellation callable. + + """ + from ..intro_detection import find_common_intro + from ..plex_db import ( + check_db_write_safety, + delete_markers, + get_marker_tag_id, + get_plex_db_path, + write_marker, + ) + + try: + db_path = get_plex_db_path(config.plex_config_folder) + except FileNotFoundError as exc: + logger.warning(f"Cannot write intro markers: {exc}") + return + + safe, reason = check_db_write_safety(db_path) + if not safe: + logger.warning(f"Plex DB not safe to write: {reason}") + return + + tag_id = get_marker_tag_id(db_path) + seasons = fingerprint_store.get_seasons() + logger.info(f"Intro detection pass 2: comparing {len(seasons)} season(s)") + + for show_title, season_num, episodes in seasons: + if cancel_check and cancel_check(): + logger.info("Intro detection cancelled") + break + + logger.debug(f"Comparing {len(episodes)} episodes of {show_title} S{season_num:02d}") + + intro = find_common_intro( + episodes, + min_duration_sec=config.intro_min_duration_sec, + max_duration_sec=config.intro_max_duration_sec, + ) + + if intro is None: + logger.debug(f"No common intro found for {show_title} S{season_num:02d}") + continue + + logger.info( + f"Intro found for {show_title} S{season_num:02d}: " + f"{intro.start_ms}ms–{intro.end_ms}ms " + f"(confidence: {intro.confidence:.0%})" + ) + + # Write intro markers for all episodes in this season + for rating_key, _fp in episodes: + if cancel_check and cancel_check(): + break + + # Check existing marker + if not config.intro_detection_overwrite: + try: + item = plex.fetchItem(rating_key) + if getattr(item, "hasIntroMarker", False): + logger.debug(f"Intro marker exists for item {rating_key}, skipping") + continue + except Exception: + pass + + if config.intro_detection_overwrite: + delete_markers(db_path, rating_key, tag_id, "intro") + + write_marker( + db_path=db_path, + metadata_item_id=rating_key, + tag_id=tag_id, + marker_type="intro", + start_ms=intro.start_ms, + end_ms=intro.end_ms, + is_final=False, + ) diff --git a/plex_generate_previews/jobs/worker.py b/plex_generate_previews/jobs/worker.py index 0ed5ee90..777039ec 100644 --- a/plex_generate_previews/jobs/worker.py +++ b/plex_generate_previews/jobs/worker.py @@ -223,6 +223,7 @@ def assign_task( job_id: str | None = None, library_name: str = "", cancel_check=None, + fingerprint_store=None, ) -> None: """Assign a new task to this worker. @@ -237,6 +238,7 @@ def assign_task( job_id: Optional job identifier for multi-job dispatch routing library_name: Library name the item belongs to cancel_check: Optional callable returning True when job is cancelled + fingerprint_store: Optional IntroFingerprintStore for intro detection """ if self.is_busy: @@ -292,7 +294,7 @@ def assign_task( # Start processing in background thread self.current_thread = threading.Thread( target=self._process_item, - args=(item_key, config, plex, progress_callback), + args=(item_key, config, plex, progress_callback, fingerprint_store), daemon=True, ) self.current_thread.start() @@ -303,6 +305,7 @@ def _process_item( config: Config, plex, progress_callback=None, + fingerprint_store=None, ) -> None: """Process a media item in the background thread. @@ -311,6 +314,7 @@ def _process_item( config: Configuration object plex: Plex server instance progress_callback: Callback function for progress updates + fingerprint_store: Optional IntroFingerprintStore for intro detection """ register_job_thread() @@ -345,6 +349,7 @@ def _process_item( ffmpeg_threads_override=self.ffmpeg_threads, cancel_check=self.cancel_check, worker_name=self.display_name, + fingerprint_store=fingerprint_store, ) self.outcome_counts[result.value] += 1 if result == ProcessingResult.FAILED: @@ -803,7 +808,7 @@ def _get_plex_media_info(self, plex, item_key: str) -> tuple[str, str]: """ try: - from .plex_client import retry_plex_call + from ..plex_client import retry_plex_call data = retry_plex_call(plex.query, item_key) if data is not None: diff --git a/plex_generate_previews/plex_db.py b/plex_generate_previews/plex_db.py new file mode 100644 index 00000000..b6b57c30 --- /dev/null +++ b/plex_generate_previews/plex_db.py @@ -0,0 +1,356 @@ +"""Plex SQLite database access layer for marker management. + +Provides safe read/write access to the Plex Media Server SQLite database +for creating intro and credits markers. All writes target the ``taggings`` +table only — the ``tags`` table has FTS4 triggers with a custom ICU +tokenizer that would fail with standard SQLite, so it is read-only. + +Safety guarantees: +- ``PRAGMA busy_timeout = 5000`` on every connection +- Single INSERT + COMMIT per write call (minimal lock time) +- Connections closed immediately after each operation +- Docker-on-Windows volume mounts detected and rejected (broken POSIX locking) +- No connection pooling — each call opens and closes its own connection +""" + +import json +import os +import sqlite3 +import time + +from loguru import logger + +# --------------------------------------------------------------------------- +# Path resolution +# --------------------------------------------------------------------------- + +_PLEX_DB_RELATIVE = os.path.join("Plug-in Support", "Databases", "com.plexapp.plugins.library.db") + +# Plex config folder may contain a nested "Library/Application Support/ +# Plex Media Server" subtree (host-mounted) or point directly at the +# Plex data root (container mount). +_PLEX_SUBTREES = [ + "", # direct mount (e.g. /plex) + os.path.join("Library", "Application Support", "Plex Media Server"), +] + + +def get_plex_db_path(plex_config_folder: str) -> str: + """Resolve the path to the Plex library database. + + Tries common Plex directory layouts to find + ``com.plexapp.plugins.library.db``. + + Args: + plex_config_folder: Value of ``PLEX_CONFIG_FOLDER`` / plex_config_folder + setting. + + Returns: + Absolute path to the database file. + + Raises: + FileNotFoundError: If the database cannot be found. + + """ + for subtree in _PLEX_SUBTREES: + candidate = os.path.join(plex_config_folder, subtree, _PLEX_DB_RELATIVE) + if os.path.isfile(candidate): + return os.path.realpath(candidate) + + raise FileNotFoundError( + f"Plex database not found under {plex_config_folder}. " + f"Checked paths: " + ", ".join(os.path.join(plex_config_folder, s, _PLEX_DB_RELATIVE) for s in _PLEX_SUBTREES) + ) + + +# --------------------------------------------------------------------------- +# Safety checks +# --------------------------------------------------------------------------- + + +def check_db_write_safety(db_path: str) -> tuple[bool, str]: + """Check whether it is safe to write to the Plex database. + + Detects: + - Missing or unwritable database file + - Docker-on-Windows volume mounts (broken POSIX advisory locking) + + Args: + db_path: Absolute path to the Plex database. + + Returns: + ``(True, "")`` if safe, ``(False, reason)`` otherwise. + + """ + if not os.path.isfile(db_path): + return False, f"Database file not found: {db_path}" + + if not os.access(db_path, os.W_OK): + return False, f"Database file is not writable: {db_path}" + + # Detect Docker-on-Windows: POSIX advisory locking is unsupported + # on Windows Docker volume mounts (CIFS/SMB). We test by attempting + # a shared flock on the file — if it raises, locking is broken. + try: + import fcntl + + fd = os.open(db_path, os.O_RDONLY) + try: + fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB) + fcntl.flock(fd, fcntl.LOCK_UN) + finally: + os.close(fd) + except ImportError: + # fcntl not available (Windows native Python) — not in Docker + return ( + False, + "POSIX file locking not available (Windows). Stop Plex Media Server before writing markers.", + ) + except OSError as exc: + return ( + False, + f"File locking test failed on {db_path}: {exc}. " + "This typically indicates Docker on Windows where volume mounts " + "do not support POSIX advisory locking. Stop Plex Media Server " + "before writing markers, or use a Linux/macOS host.", + ) + + return True, "" + + +# --------------------------------------------------------------------------- +# Connection helper +# --------------------------------------------------------------------------- + + +def _connect(db_path: str) -> sqlite3.Connection: + """Open a connection with safety pragmas. + + Sets ``busy_timeout`` so concurrent access with Plex (which uses WAL + mode) retries instead of immediately failing with SQLITE_BUSY. + + """ + conn = sqlite3.connect(db_path, timeout=10) + conn.execute("PRAGMA busy_timeout = 5000") + conn.row_factory = sqlite3.Row + return conn + + +# --------------------------------------------------------------------------- +# Tag ID lookup +# --------------------------------------------------------------------------- + +# Marker tags use tag_type = 12 in the Plex ``tags`` table. +_MARKER_TAG_TYPE = 12 + + +def get_marker_tag_id(db_path: str) -> int: + """Look up the tag_id for marker tags in the Plex database. + + This is a **read-only** operation. The marker tag (``tag_type=12``) + already exists in every Plex installation — we never INSERT into the + ``tags`` table because it has FTS4 triggers with a custom ICU + tokenizer that would fail with standard SQLite. + + Args: + db_path: Path to the Plex database. + + Returns: + The ``tag_id`` integer. + + Raises: + RuntimeError: If no marker tag is found (corrupted Plex install). + + """ + conn = _connect(db_path) + try: + row = conn.execute( + "SELECT id FROM tags WHERE tag_type = ? ORDER BY id LIMIT 1", + (_MARKER_TAG_TYPE,), + ).fetchone() + if row is None: + raise RuntimeError( + f"No marker tag (tag_type={_MARKER_TAG_TYPE}) found in Plex database. " + "The database may be corrupted or from an unsupported Plex version." + ) + return row["id"] + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# Marker CRUD +# --------------------------------------------------------------------------- + +# extra_data JSON templates matching Plex's own format (confirmed from +# a live database backup). +_EXTRA_DATA_INTRO = json.dumps( + {"pv:version": "5", "url": "pv%3Aversion=5"}, + separators=(",", ":"), +) +_EXTRA_DATA_CREDITS_FINAL = json.dumps( + {"pv:final": "1", "pv:version": "4", "url": "pv%3Afinal=1&pv%3Aversion=4"}, + separators=(",", ":"), +) +_EXTRA_DATA_CREDITS = json.dumps( + {"pv:version": "4", "url": "pv%3Aversion=4"}, + separators=(",", ":"), +) + + +def _extra_data_for(marker_type: str, is_final: bool) -> str: + """Return the ``extra_data`` JSON string for a marker type.""" + if marker_type == "intro": + return _EXTRA_DATA_INTRO + if is_final: + return _EXTRA_DATA_CREDITS_FINAL + return _EXTRA_DATA_CREDITS + + +def write_marker( + db_path: str, + metadata_item_id: int, + tag_id: int, + marker_type: str, + start_ms: int, + end_ms: int, + is_final: bool = True, +) -> bool: + """Write a single marker to the Plex ``taggings`` table. + + Uses an extremely short transaction: single INSERT + COMMIT. + The connection is closed immediately after. + + Args: + db_path: Path to the Plex database. + metadata_item_id: The ``ratingKey`` of the media item. + tag_id: The marker tag_id (from :func:`get_marker_tag_id`). + marker_type: ``'intro'`` or ``'credits'``. + start_ms: Marker start time in milliseconds. + end_ms: Marker end time in milliseconds. + is_final: Whether this is the final credits segment (sets + ``pv:final`` in extra_data). + + Returns: + ``True`` if the marker was written successfully. + + """ + if marker_type not in ("intro", "credits"): + raise ValueError(f"marker_type must be 'intro' or 'credits', got {marker_type!r}") + + conn = _connect(db_path) + try: + # Determine next index for this item + row = conn.execute( + 'SELECT COALESCE(MAX("index"), -1) + 1 AS next_idx FROM taggings WHERE metadata_item_id = ? AND tag_id = ?', + (metadata_item_id, tag_id), + ).fetchone() + next_index = row["next_idx"] if row else 0 + + extra_data = _extra_data_for(marker_type, is_final) + created_at = int(time.time()) + + conn.execute( + "INSERT INTO taggings " + '(metadata_item_id, tag_id, "index", text, time_offset, ' + "end_time_offset, thumb_url, created_at, extra_data) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + metadata_item_id, + tag_id, + next_index, + marker_type, + start_ms, + end_ms, + "", # thumb_url — not used for markers + created_at, + extra_data, + ), + ) + conn.commit() + logger.debug( + f"Wrote {marker_type} marker for item {metadata_item_id}: {start_ms}ms–{end_ms}ms (index={next_index})" + ) + return True + except sqlite3.Error as exc: + logger.warning(f"Failed to write {marker_type} marker for item {metadata_item_id}: {exc}") + return False + finally: + conn.close() + + +def delete_markers( + db_path: str, + metadata_item_id: int, + tag_id: int, + marker_type: str, +) -> int: + """Remove existing markers of a given type for a media item. + + Args: + db_path: Path to the Plex database. + metadata_item_id: The ``ratingKey`` of the media item. + tag_id: The marker tag_id. + marker_type: ``'intro'`` or ``'credits'``. + + Returns: + Number of markers deleted. + + """ + conn = _connect(db_path) + try: + cursor = conn.execute( + "DELETE FROM taggings WHERE metadata_item_id = ? AND tag_id = ? AND text = ?", + (metadata_item_id, tag_id, marker_type), + ) + conn.commit() + deleted = cursor.rowcount + if deleted: + logger.debug(f"Deleted {deleted} {marker_type} marker(s) for item {metadata_item_id}") + return deleted + except sqlite3.Error as exc: + logger.warning(f"Failed to delete {marker_type} markers for item {metadata_item_id}: {exc}") + return 0 + finally: + conn.close() + + +def get_existing_markers( + db_path: str, + metadata_item_id: int, + tag_id: int, +) -> list[dict]: + """Read existing markers for a media item. + + Args: + db_path: Path to the Plex database. + metadata_item_id: The ``ratingKey`` of the media item. + tag_id: The marker tag_id. + + Returns: + List of marker dicts with keys: ``type``, ``start_ms``, + ``end_ms``, ``index``, ``extra_data``. + + """ + conn = _connect(db_path) + try: + rows = conn.execute( + "SELECT text, time_offset, end_time_offset, " + '"index", extra_data ' + "FROM taggings " + "WHERE metadata_item_id = ? AND tag_id = ? " + 'ORDER BY "index"', + (metadata_item_id, tag_id), + ).fetchall() + return [ + { + "type": row["text"], + "start_ms": row["time_offset"], + "end_ms": row["end_time_offset"], + "index": row["index"], + "extra_data": row["extra_data"], + } + for row in rows + ] + finally: + conn.close() diff --git a/plex_generate_previews/processing/orchestrator.py b/plex_generate_previews/processing/orchestrator.py index 02ab2eb9..28cffc23 100644 --- a/plex_generate_previews/processing/orchestrator.py +++ b/plex_generate_previews/processing/orchestrator.py @@ -1343,6 +1343,7 @@ def process_item( ffmpeg_threads_override: int | None = None, cancel_check=None, worker_name: str = "", + fingerprint_store=None, ) -> ProcessingResult: """Process a single media item: generate thumbnails and BIF file. @@ -1393,6 +1394,12 @@ def process_item( best_result = ProcessingResult.NO_MEDIA_PARTS + # Extract media type from the XML tree for detection grouping + _video_el = data.find(".//Video") + _media_type = _video_el.attrib.get("type", "") if _video_el is not None else "" + + # fingerprint_store is passed explicitly through the worker pipeline + def _update_best(result: ProcessingResult) -> None: nonlocal best_result if _RESULT_PRIORITY[result] > _RESULT_PRIORITY[best_result]: @@ -1481,6 +1488,16 @@ def _update_best(result: ProcessingResult) -> None: f"BIF exists at {index_bif}", worker_name, ) + # Run detection even when BIF already exists + _run_marker_detection( + item_key, + media_file, + _media_type, + plex, + config, + cancel_check, + fingerprint_store, + ) continue logger.info(f"Generating BIF for {media_file} -> {index_bif}") @@ -1514,6 +1531,16 @@ def _update_best(result: ProcessingResult) -> None: "", worker_name, ) + # Run detection after successful BIF generation + _run_marker_detection( + item_key, + media_file, + _media_type, + plex, + config, + cancel_check, + fingerprint_store, + ) except (CancellationError, CodecNotSupportedError): raise except RuntimeError as e: @@ -1540,3 +1567,219 @@ def _update_best(result: ProcessingResult) -> None: _cleanup_temp_directory(tmp_path) return best_result + + +# --------------------------------------------------------------------------- +# Marker detection helpers (credits + intro fingerprinting) +# --------------------------------------------------------------------------- + + +def _run_marker_detection( + item_key: str, + media_file: str, + media_type: str, + plex, + config: Config, + cancel_check=None, + fingerprint_store=None, + detection_stats: dict = None, +) -> None: + """Run credits detection and/or intro fingerprinting for a media item. + + This is called after BIF generation (or skip). Failures are logged + as warnings and never affect the BIF processing result. + + Args: + item_key: Plex metadata key (e.g. ``/library/metadata/12345``). + media_file: Local path to the video file. + media_type: ``"episode"`` or ``"movie"`` (empty string accepted). + plex: Plex server instance. + config: Configuration object. + cancel_check: Optional cancellation callable. + fingerprint_store: Optional IntroFingerprintStore for pass-1 + intro fingerprinting. + + """ + if not os.path.isfile(media_file): + return + + # Credits detection (movies + episodes) + if config.credits_detection_enabled: + try: + _detect_and_write_credits(item_key, media_file, plex, config, cancel_check, detection_stats) + except CancellationError: + raise + except Exception as exc: + logger.warning(f"Credits detection failed for {media_file}: {exc}") + + # Intro fingerprinting pass 1 (episodes only) + if config.intro_detection_enabled and fingerprint_store is not None and media_type == "episode": + try: + _fingerprint_for_intro(item_key, media_file, plex, config, fingerprint_store, cancel_check) + except CancellationError: + raise + except Exception as exc: + logger.warning(f"Intro fingerprinting failed for {media_file}: {exc}") + + +def _detect_and_write_credits( + item_key: str, + media_file: str, + plex, + config: Config, + cancel_check=None, + detection_stats: dict = None, +) -> None: + """Detect credits and write marker to Plex database. + + Skips if credits marker already exists (unless overwrite is on). + """ + from .credits_detection import CreditsDetectionConfig, detect_credits + from .plex_db import ( + check_db_write_safety, + delete_markers, + get_marker_tag_id, + get_plex_db_path, + write_marker, + ) + + rating_key = int(item_key.rstrip("/").split("/")[-1]) + + # Check existing markers via plexapi + if not config.credits_detection_overwrite: + try: + item = retry_plex_call(plex.fetchItem, rating_key) + if getattr(item, "hasCreditsMarker", False): + logger.info(f"Credits marker exists for {media_file}, skipping") + if detection_stats is not None: + detection_stats["credits_skipped"] = detection_stats.get("credits_skipped", 0) + 1 + return + except Exception as exc: + logger.debug(f"Could not check existing markers: {exc}") + + # Check DB safety + db_path = get_plex_db_path(config.plex_config_folder) + safe, reason = check_db_write_safety(db_path) + if not safe: + logger.warning(f"Plex DB not safe to write: {reason}") + return + + # Get video duration via pymediainfo + from pymediainfo import MediaInfo + + mi = MediaInfo.parse(media_file) + duration_ms = 0 + for track in mi.video_tracks: + if track.duration: + duration_ms = float(track.duration) + break + if not duration_ms: + for track in mi.general_tracks: + if track.duration: + duration_ms = float(track.duration) + break + if not duration_ms: + logger.warning(f"Cannot determine duration for {media_file}") + return + + total_duration_sec = duration_ms / 1000.0 + + det_config = CreditsDetectionConfig( + enabled=True, + scan_last_pct=config.credits_scan_last_pct, + min_credits_duration=config.credits_min_duration, + ) + + segment = detect_credits( + media_file=media_file, + total_duration_sec=total_duration_sec, + ffmpeg_path=config.ffmpeg_path, + config=det_config, + cancel_check=cancel_check, + ) + + if segment is None: + logger.info(f"No credits detected in {media_file}") + return + + # Skip low-confidence detections to reduce false positives + if segment.confidence < 0.5: + logger.info(f"Credits detection below confidence threshold for {media_file} ({segment.confidence:.0%} < 50%)") + return + + tag_id = get_marker_tag_id(db_path) + + # Delete existing credits markers if overwriting + if config.credits_detection_overwrite: + delete_markers(db_path, rating_key, tag_id, "credits") + + success = write_marker( + db_path=db_path, + metadata_item_id=rating_key, + tag_id=tag_id, + marker_type="credits", + start_ms=segment.start_ms, + end_ms=segment.end_ms, + is_final=True, + ) + + if success: + if detection_stats is not None: + detection_stats["credits_written"] = detection_stats.get("credits_written", 0) + 1 + logger.info( + f"Credits marker: {media_file} " + f"{segment.start_ms}ms–{segment.end_ms}ms " + f"({segment.method}, {segment.confidence:.0%})" + ) + + +def _fingerprint_for_intro( + item_key: str, + media_file: str, + plex, + config: Config, + fingerprint_store, + cancel_check=None, +) -> None: + """Generate a chromaprint fingerprint and store it for later comparison. + + Retrieves show title and season number from the Plex API to group + the fingerprint with other episodes of the same season. + """ + from .intro_detection import check_fpcalc_available, fingerprint_episode + + if not check_fpcalc_available(): + logger.info("fpcalc not available, skipping intro fingerprinting") + return + + rating_key = int(item_key.rstrip("/").split("/")[-1]) + + # Single fetch for both marker check and metadata extraction + try: + item = retry_plex_call(plex.fetchItem, rating_key) + except Exception as exc: + logger.debug(f"Cannot fetch episode metadata for grouping: {exc}") + return + + # Check existing markers + if not config.intro_detection_overwrite: + if getattr(item, "hasIntroMarker", False): + logger.info(f"Intro marker exists for {media_file}, skipping") + return + + # Get show title and season number for grouping + show_title = getattr(item, "grandparentTitle", None) + season_number = getattr(item, "parentIndex", None) + if not show_title or season_number is None: + logger.debug(f"Cannot determine show/season for {media_file}") + return + + fp = fingerprint_episode( + media_file, + duration_limit_sec=config.intro_scan_duration_sec, + cancel_check=cancel_check, + ) + + if fp is not None: + fingerprint_store.add(show_title, int(season_number), rating_key, fp) + logger.debug(f"Fingerprinted {show_title} S{season_number:02d} (item {rating_key}, {len(fp)} samples)") diff --git a/plex_generate_previews/web/routes/__init__.py b/plex_generate_previews/web/routes/__init__.py index 4422ebcb..454389b7 100644 --- a/plex_generate_previews/web/routes/__init__.py +++ b/plex_generate_previews/web/routes/__init__.py @@ -15,6 +15,7 @@ # Order doesn't matter; each module imports `main` or `api` from this package. from . import ( # noqa: E402 api_bif, # noqa: F401 + api_detection, # noqa: F401 api_jobs, # noqa: F401 api_plex, # noqa: F401 api_schedules, # noqa: F401 diff --git a/plex_generate_previews/web/routes/api_detection.py b/plex_generate_previews/web/routes/api_detection.py new file mode 100644 index 00000000..1a0f7a5b --- /dev/null +++ b/plex_generate_previews/web/routes/api_detection.py @@ -0,0 +1,218 @@ +"""API routes for marker detection debugging and preview. + +Provides endpoints to run detection analysis on individual media items +and return raw detection data (black frames, silence regions, combined +result) without writing markers to the Plex database. +""" + +import os + +from flask import jsonify, request +from loguru import logger + +from ..auth import api_token_required +from . import api + + +@api.route("/detection/analyze", methods=["POST"]) +@api_token_required +def analyze_detection(): + """Run credits detection on a media item and return raw results. + + Does NOT write markers — purely diagnostic. Returns the detected + black frames, silence regions, and the combined credits segment + so the user can visualize why detection did or didn't work. + + Request JSON: + item_key: Plex metadata key (e.g. "/library/metadata/12345") + + Returns JSON with: duration, black_frames, silence_regions, + credits_segment, and detection parameters used. + """ + data = request.get_json() or {} + item_key = data.get("item_key", "").strip() + + if not item_key: + return jsonify({"error": "item_key is required"}), 400 + + try: + from ...config import load_config + from ...credits_detection import ( + CreditsDetectionConfig, + _combine_detections, + _run_blackdetect, + _run_silencedetect, + ) + from ...plex_client import plex_server, retry_plex_call + from ..settings_manager import get_settings_manager + + config = load_config() + settings = get_settings_manager() + + if settings.plex_url: + config.plex_url = settings.plex_url + if settings.plex_token: + config.plex_token = settings.plex_token + if settings.plex_config_folder: + config.plex_config_folder = settings.plex_config_folder + + from ...config import normalize_path_mappings + + path_mappings = normalize_path_mappings(settings) + if path_mappings: + config.path_mappings = path_mappings + + plex = plex_server(config) + + # Resolve file path + tree_data = retry_plex_call(plex.query, f"{item_key}/tree") + media_part = tree_data.find(".//MediaPart") + if media_part is None: + return jsonify({"error": "No media parts found for this item"}), 404 + + plex_path = media_part.attrib.get("file", "") + mappings = getattr(config, "path_mappings", None) or [] + if mappings: + from ...media_processing import plex_path_to_local, sanitize_path + + media_file = sanitize_path(plex_path_to_local(plex_path, mappings)) + else: + from ...media_processing import sanitize_path + + media_file = sanitize_path(plex_path) + + if not os.path.isfile(media_file): + return jsonify({"error": f"File not found: {media_file}"}), 404 + + # Get duration + from pymediainfo import MediaInfo + + mi = MediaInfo.parse(media_file) + duration_ms = 0 + for track in mi.video_tracks: + if track.duration: + duration_ms = float(track.duration) + break + if not duration_ms: + for track in mi.general_tracks: + if track.duration: + duration_ms = float(track.duration) + break + if not duration_ms: + return jsonify({"error": "Cannot determine video duration"}), 400 + + total_duration_sec = duration_ms / 1000.0 + + # Build detection config from settings + scan_last_pct = float(settings.get("credits_scan_last_pct", 25.0)) + min_duration = float(settings.get("credits_min_duration", 15.0)) + det_config = CreditsDetectionConfig( + scan_last_pct=scan_last_pct, + min_credits_duration=min_duration, + ) + + seek_to = total_duration_sec * (1.0 - det_config.scan_last_pct / 100.0) + + # Run detection + black_frames = _run_blackdetect( + media_file, + seek_to, + config.ffmpeg_path, + black_min_duration=det_config.black_min_duration, + pix_threshold=det_config.black_pix_threshold, + ) + + silence_regions = _run_silencedetect( + media_file, + seek_to, + config.ffmpeg_path, + noise_threshold=det_config.silence_noise_threshold, + silence_duration=det_config.silence_min_duration, + ) + + segment = _combine_detections( + black_frames, + silence_regions, + total_duration_sec, + min_credits_duration_sec=det_config.min_credits_duration, + max_credits_start_pct=det_config.max_credits_start_pct, + ) + + # Get existing markers + rating_key = int(item_key.rstrip("/").split("/")[-1]) + existing_markers = [] + try: + item = retry_plex_call(plex.fetchItem, rating_key) + for marker in getattr(item, "markers", []) or []: + existing_markers.append( + { + "type": getattr(marker, "type", ""), + "start_ms": getattr(marker, "start", 0), + "end_ms": getattr(marker, "end", 0), + } + ) + except Exception: + pass + + # Get item title for display + title = "" + try: + video_el = tree_data.find(".//Video") + if video_el is not None: + title = video_el.attrib.get("title", "") + gp_title = video_el.attrib.get("grandparentTitle", "") + if gp_title: + se = video_el.attrib.get("parentIndex", "") + ep = video_el.attrib.get("index", "") + title = f"{gp_title} S{se}E{ep} — {title}" + except Exception: + pass + + return jsonify( + { + "title": title, + "file": media_file, + "duration_sec": total_duration_sec, + "scan_start_sec": seek_to, + "config": { + "scan_last_pct": det_config.scan_last_pct, + "min_credits_duration": det_config.min_credits_duration, + "black_min_duration": det_config.black_min_duration, + "black_pix_threshold": det_config.black_pix_threshold, + "silence_noise_threshold": det_config.silence_noise_threshold, + "silence_min_duration": det_config.silence_min_duration, + "max_credits_start_pct": det_config.max_credits_start_pct, + }, + "black_frames": [ + { + "start": bf.start, + "end": bf.end, + "duration": bf.duration, + } + for bf in black_frames + ], + "silence_regions": [ + { + "start": sr.start, + "end": sr.end, + "duration": sr.duration, + } + for sr in silence_regions + ], + "credits_segment": ( + { + "start_ms": segment.start_ms, + "end_ms": segment.end_ms, + "confidence": segment.confidence, + "method": segment.method, + } + if segment + else None + ), + "existing_markers": existing_markers, + } + ) + + except Exception as exc: + logger.error(f"Detection analysis failed: {exc}") + return jsonify({"error": str(exc)}), 500 diff --git a/plex_generate_previews/web/routes/api_settings.py b/plex_generate_previews/web/routes/api_settings.py index d650cef7..59a114ca 100644 --- a/plex_generate_previews/web/routes/api_settings.py +++ b/plex_generate_previews/web/routes/api_settings.py @@ -117,6 +117,16 @@ def get_settings(): "requeue_max_age_minutes": settings.get("requeue_max_age_minutes", 720), "plex_webhook_enabled": bool(settings.get("plex_webhook_enabled", False)), "plex_webhook_public_url": settings.get("plex_webhook_public_url", "") or "", + # Credits/intro detection + "credits_detection_enabled": settings.get("credits_detection_enabled", False), + "credits_detection_overwrite": settings.get("credits_detection_overwrite", False), + "credits_scan_last_pct": settings.get("credits_scan_last_pct", 25.0), + "credits_min_duration": settings.get("credits_min_duration", 15.0), + "intro_detection_enabled": settings.get("intro_detection_enabled", False), + "intro_detection_overwrite": settings.get("intro_detection_overwrite", False), + "intro_scan_duration_sec": settings.get("intro_scan_duration_sec", 600.0), + "intro_min_duration_sec": settings.get("intro_min_duration_sec", 15.0), + "intro_max_duration_sec": settings.get("intro_max_duration_sec", 120.0), } ) @@ -162,6 +172,16 @@ def save_settings(): "requeue_max_age_minutes", "plex_webhook_enabled", "plex_webhook_public_url", + # Credits/intro detection + "credits_detection_enabled", + "credits_detection_overwrite", + "credits_scan_last_pct", + "credits_min_duration", + "intro_detection_enabled", + "intro_detection_overwrite", + "intro_scan_duration_sec", + "intro_min_duration_sec", + "intro_max_duration_sec", ] updates = {k: v for k, v in data.items() if k in allowed_fields} diff --git a/plex_generate_previews/web/routes/api_system.py b/plex_generate_previews/web/routes/api_system.py index 81aae777..70555514 100644 --- a/plex_generate_previews/web/routes/api_system.py +++ b/plex_generate_previews/web/routes/api_system.py @@ -979,6 +979,32 @@ def health_check(): return jsonify({"status": "healthy"}) +@api.route("/system/plex-db-status") +@setup_or_auth_required +def plex_db_status(): + """Check if the Plex database is accessible and safe to write. + + Used by the frontend to conditionally show Docker-on-Windows + warnings in the credits/intro detection settings. + """ + try: + from ...plex_db import check_db_write_safety, get_plex_db_path + from ..settings_manager import get_settings_manager + + settings = get_settings_manager() + plex_config = settings.plex_config_folder or "/plex" + + try: + db_path = get_plex_db_path(plex_config) + except FileNotFoundError as exc: + return jsonify({"safe": False, "reason": str(exc), "db_path": ""}) + + safe, reason = check_db_write_safety(db_path) + return jsonify({"safe": safe, "reason": reason, "db_path": db_path}) + except Exception as exc: + return jsonify({"safe": False, "reason": str(exc), "db_path": ""}) + + # --------------------------------------------------------------------------- # Log history (reads from the JSONL app.log file) # --------------------------------------------------------------------------- diff --git a/plex_generate_previews/web/routes/job_runner.py b/plex_generate_previews/web/routes/job_runner.py index 9d4198bc..1196a240 100644 --- a/plex_generate_previews/web/routes/job_runner.py +++ b/plex_generate_previews/web/routes/job_runner.py @@ -222,6 +222,20 @@ def job_thread_filter(record: dict) -> bool: if settings.get("plex_local_videos_path_mapping"): config.plex_local_videos_path_mapping = settings.get("plex_local_videos_path_mapping") + # Credits/intro detection settings + config.credits_detection_enabled = bool(settings.get("credits_detection_enabled", False)) + config.credits_detection_overwrite = bool(settings.get("credits_detection_overwrite", False)) + config.credits_scan_last_pct = max(5.0, min(50.0, float(settings.get("credits_scan_last_pct", 25.0)))) + config.credits_min_duration = max(5.0, min(120.0, float(settings.get("credits_min_duration", 15.0)))) + config.intro_detection_enabled = bool(settings.get("intro_detection_enabled", False)) + config.intro_detection_overwrite = bool(settings.get("intro_detection_overwrite", False)) + config.intro_scan_duration_sec = max( + 120.0, + min(1800.0, float(settings.get("intro_scan_duration_sec", 600.0))), + ) + config.intro_min_duration_sec = max(5.0, min(60.0, float(settings.get("intro_min_duration_sec", 15.0)))) + config.intro_max_duration_sec = max(30.0, min(300.0, float(settings.get("intro_max_duration_sec", 120.0)))) + if config_overrides: for key, value in config_overrides.items(): if key == "selected_libraries": diff --git a/plex_generate_previews/web/routes/pages.py b/plex_generate_previews/web/routes/pages.py index 9a7d5e4e..4622b5de 100644 --- a/plex_generate_previews/web/routes/pages.py +++ b/plex_generate_previews/web/routes/pages.py @@ -106,6 +106,13 @@ def bif_viewer(): return render_template("bif_viewer.html") +@main.route("/detection-debug") +@login_required +def detection_debug(): + """Detection debug viewer for troubleshooting credits/intro detection.""" + return render_template("detection_debug.html") + + @main.route("/setup") def setup_wizard(): """Setup wizard page.""" diff --git a/plex_generate_previews/web/templates/base.html b/plex_generate_previews/web/templates/base.html index 00f1ade1..a85e0caa 100644 --- a/plex_generate_previews/web/templates/base.html +++ b/plex_generate_previews/web/templates/base.html @@ -71,7 +71,7 @@
+ Enter a Plex item key to run credits detection and visualize the raw black frames, + silence regions, and resulting credits marker. No markers are written — this is purely diagnostic. +
+Title:
+File:
Duration:
+| Start | End | Duration |
|---|
| Start | End | Duration |
|---|
Running detection analysis (this may take a minute for long videos)...
+fpcalc (included in Docker image).
+ Back up your database before first use.
+