From d9d987bede828621100d4f26bd30ddb0d2716b8f Mon Sep 17 00:00:00 2001 From: "Adam J. Weigold" Date: Sat, 21 Mar 2026 22:11:59 -0500 Subject: [PATCH 1/3] feat: add intro and credits detection with Plex marker support (#157) Adds automatic detection of intro and credits segments in media files, writing "Skip Intro" / "Skip Credits" markers directly to the Plex SQLite database. Both features are disabled by default and marked Experimental in the Settings UI. Credits detection uses FFmpeg blackdetect + silencedetect filters on the last portion of each video. Intro detection uses two-pass chromaprint audio fingerprinting: pass 1 fingerprints each episode in parallel, pass 2 compares fingerprints within each season to find the recurring intro theme. Writes only to the `taggings` table (no FTS triggers). Never inserts into the `tags` table. Uses PRAGMA busy_timeout = 5000, single INSERT + COMMIT per call, immediate connection close. Docker-on-Windows detected via failed fcntl.flock and warned in the UI. The Plex REST marker API was tested and confirmed to return 400 for intro/credits types (only bookmarks work), matching all community tools that use direct SQLite writes. Adds libchromaprint-tools to the Docker image for fpcalc. Refs #157 Co-Authored-By: Claude Opus 4.6 (1M context) --- Dockerfile | 1 + README.md | 2 + docs/guides.md | 38 ++ docs/reference.md | 38 ++ plex_generate_previews/config.py | 13 + plex_generate_previews/credits_detection.py | 388 ++++++++++++++++++ plex_generate_previews/intro_detection.py | 373 +++++++++++++++++ plex_generate_previews/media_processing.py | 245 +++++++++++ plex_generate_previews/plex_db.py | 375 +++++++++++++++++ plex_generate_previews/processing.py | 106 +++++ .../web/routes/api_settings.py | 26 ++ .../web/routes/api_system.py | 26 ++ .../web/routes/job_runner.py | 29 ++ .../web/templates/settings.html | 158 +++++++ tests/conftest.py | 10 + tests/test_credits_detection.py | 267 ++++++++++++ tests/test_intro_detection.py | 312 ++++++++++++++ tests/test_plex_db.py | 348 ++++++++++++++++ 18 files changed, 2755 insertions(+) create mode 100644 plex_generate_previews/credits_detection.py create mode 100644 plex_generate_previews/intro_detection.py create mode 100644 plex_generate_previews/plex_db.py create mode 100644 tests/test_credits_detection.py create mode 100644 tests/test_intro_detection.py create mode 100644 tests/test_plex_db.py diff --git a/Dockerfile b/Dockerfile index cbfe870c..e45df3c0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,6 +47,7 @@ ARG GIT_SHA=unknown RUN apt-get update && \ apt-get install -y --no-install-recommends \ mediainfo python3 python3-pip gosu pciutils git \ + libchromaprint-tools \ mesa-va-drivers libva2 libva-drm2 vainfo && \ if [ "$(dpkg --print-architecture)" = "amd64" ]; then \ apt-get install -y --no-install-recommends \ diff --git a/README.md b/README.md index de057c27..fcfd9acf 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 | --- diff --git a/docs/guides.md b/docs/guides.md index 70f36ef1..76fc3033 100644 --- a/docs/guides.md +++ b/docs/guides.md @@ -405,6 +405,44 @@ locust -f tests/load/locustfile.py --headless -u 50 -r 10 -t 60s --- +## 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`. + +--- + ## FAQ ### General diff --git a/docs/reference.md b/docs/reference.md index 4154a417..4f669806 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -71,6 +71,44 @@ GPU settings are configured per-GPU in **Settings** → **Processing Options**. > set `cpu_threads=0` and `cpu_fallback_threads>0`. > This prevents regular CPU main-queue work while still allowing GPU-failed items to be retried on CPU. +### 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.py b/plex_generate_previews/config.py index fe4fab4e..184dc24a 100644 --- a/plex_generate_previews/config.py +++ b/plex_generate_previews/config.py @@ -522,6 +522,19 @@ class Config: # Exclude paths: list of {"value": str, "type": "path"|"regex"}; path = prefix match, regex = full match exclude_paths: Optional[List[Dict[str, str]]] = 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..c3a386a9 --- /dev/null +++ b/plex_generate_previews/credits_detection.py @@ -0,0 +1,388 @@ +"""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 dataclasses import dataclass +from typing import Callable, List, Optional + +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: Optional[Callable] = 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: Optional[Callable] = 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: Optional[float] = 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 _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, +) -> Optional[CreditsSegment]: + """Combine black frame and silence detections to identify credits. + + Strategy: + 1. Find combined black+silence regions near the end of the video. + 2. The earliest qualifying region is the credits start. + 3. Credits extend to the end of the video. + 4. Require minimum duration to avoid false positives. + 5. Fall back to black-only or silence-only if no combined match. + + 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) + + # Strategy 1: Combined black + silence (highest confidence) + for bf in sorted(black_frames, key=lambda f: f.start): + 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.9, + method="black+silence", + ) + + # Strategy 2: Black frames only (medium confidence) + for bf in sorted(black_frames, key=lambda f: f.start): + if bf.start < min_start_time: + continue + credits_duration = total_duration_sec - bf.start + if credits_duration >= min_credits_duration_sec: + return CreditsSegment( + start_ms=int(bf.start * 1000), + end_ms=end_ms, + confidence=0.6, + method="black_only", + ) + + # Strategy 3: Silence only (lower confidence) + 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: Optional[CreditsDetectionConfig] = None, + cancel_check: Optional[Callable] = None, +) -> Optional[CreditsSegment]: + """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 " + f"({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.debug( + f"Credits detection: scanning from {seek_to:.0f}s " + f"(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, " + f"{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..3602cf1e --- /dev/null +++ b/plex_generate_previews/intro_detection.py @@ -0,0 +1,373 @@ +"""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 dataclasses import dataclass +from typing import Callable, Dict, List, Optional, Tuple + +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, +) -> Optional[List[int]]: + """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: Optional[Callable] = None, +) -> Optional[List[int]]: + """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). + + 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, +) -> Optional[IntroSegment]: + """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, +) -> Optional[IntroSegment]: + """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 + + all_matches: List[MatchRegion] = [] + + # Pairwise comparison (first episode compared to all others) + base_key, base_fp = fingerprints[0] + for other_key, other_fp in fingerprints[1:]: + matches = _compare_fingerprints(base_fp, other_fp) + all_matches.extend(matches) + + if not all_matches: + logger.debug("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.debug( + f"Found common intro: {result.start_ms}ms–{result.end_ms}ms " + f"(confidence: {result.confidence:.0%})" + ) + return result diff --git a/plex_generate_previews/media_processing.py b/plex_generate_previews/media_processing.py index 43659392..3da7c43d 100644 --- a/plex_generate_previews/media_processing.py +++ b/plex_generate_previews/media_processing.py @@ -1700,6 +1700,7 @@ def process_item( ffmpeg_threads_override: Optional[int] = None, cancel_check=None, worker_name: str = "", + fingerprint_store=None, ) -> ProcessingResult: """Process a single media item: generate thumbnails and BIF file. @@ -1753,6 +1754,14 @@ 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 "" + + # Resolve fingerprint store (passed directly or via config) + if fingerprint_store is None: + fingerprint_store = getattr(config, "_fingerprint_store", None) + def _update_best(result: ProcessingResult) -> None: nonlocal best_result if _RESULT_PRIORITY[result] > _RESULT_PRIORITY[best_result]: @@ -1851,6 +1860,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}") @@ -1884,6 +1903,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: @@ -1912,3 +1941,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, +) -> 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) + 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, +) -> 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.debug(f"Credits marker exists for {media_file}, skipping") + 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.debug(f"No credits detected in {media_file}") + 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: + 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.debug("fpcalc not available, skipping intro fingerprinting") + return + + rating_key = int(item_key.rstrip("/").split("/")[-1]) + + # Check existing markers + if not config.intro_detection_overwrite: + try: + item = retry_plex_call(plex.fetchItem, rating_key) + if getattr(item, "hasIntroMarker", False): + logger.debug(f"Intro marker exists for {media_file}, skipping") + return + except Exception: + pass + + # Get show title and season number for grouping + try: + item = retry_plex_call(plex.fetchItem, rating_key) + 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 + except Exception as exc: + logger.debug(f"Cannot fetch episode metadata for grouping: {exc}") + 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} " + f"(item {rating_key}, {len(fp)} samples)" + ) diff --git a/plex_generate_previews/plex_db.py b/plex_generate_previews/plex_db.py new file mode 100644 index 00000000..1e011653 --- /dev/null +++ b/plex_generate_previews/plex_db.py @@ -0,0 +1,375 @@ +"""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 typing import Dict, List, Tuple + +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 = ? 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}: " + f"{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.py b/plex_generate_previews/processing.py index 7328b25c..da672052 100644 --- a/plex_generate_previews/processing.py +++ b/plex_generate_previews/processing.py @@ -96,6 +96,15 @@ def _merge_outcome(result_dict): logger.info("Running in headless mode (no console display)") + # Create fingerprint store for intro detection pass 1. + # Attached to config so it flows through the worker pipeline. + fingerprint_store = None + if getattr(config, "intro_detection_enabled", False): + from .intro_detection import IntroFingerprintStore + + fingerprint_store = IntroFingerprintStore() + config._fingerprint_store = fingerprint_store + _dispatch_started = False def _dispatch_items(items, library_name): @@ -240,6 +249,10 @@ 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: + _process_intro_fingerprints(fingerprint_store, config, plex, cancel_check) + 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) @@ -320,3 +333,96 @@ def _dispatch_items(items, library_name): f"Failed to clean up working temp folder " f"{config.working_tmp_folder}: {cleanup_error}" ) + + +def _process_intro_fingerprints(fingerprint_store, config, plex, cancel_check=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/web/routes/api_settings.py b/plex_generate_previews/web/routes/api_settings.py index 0d1bf46f..2ec7bdcb 100644 --- a/plex_generate_previews/web/routes/api_settings.py +++ b/plex_generate_previews/web/routes/api_settings.py @@ -118,6 +118,22 @@ def get_settings(): "webhook_secret": "****" if settings.get("webhook_secret") else "", "auto_requeue_on_restart": settings.get("auto_requeue_on_restart", True), "requeue_max_age_minutes": settings.get("requeue_max_age_minutes", 720), + # 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 +178,16 @@ def save_settings(): "webhook_secret", "auto_requeue_on_restart", "requeue_max_age_minutes", + # 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 17bf0792..dd6759fc 100644 --- a/plex_generate_previews/web/routes/api_system.py +++ b/plex_generate_previews/web/routes/api_system.py @@ -239,6 +239,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 6354deff..3eb16d67 100644 --- a/plex_generate_previews/web/routes/job_runner.py +++ b/plex_generate_previews/web/routes/job_runner.py @@ -223,6 +223,35 @@ def job_thread_filter(record: dict) -> bool: "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 = float( + settings.get("credits_scan_last_pct", 25.0) + ) + config.credits_min_duration = 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 = float( + settings.get("intro_scan_duration_sec", 600.0) + ) + config.intro_min_duration_sec = float( + settings.get("intro_min_duration_sec", 15.0) + ) + config.intro_max_duration_sec = 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/templates/settings.html b/plex_generate_previews/web/templates/settings.html index cb4efb85..2485a093 100644 --- a/plex_generate_previews/web/templates/settings.html +++ b/plex_generate_previews/web/templates/settings.html @@ -451,6 +451,118 @@
Job History
+ +
+
+ Credits Detection + Experimental +
+
+
+ Note: Writes "Skip Credits" markers directly to the Plex SQLite database. + Back up your database before first use. +
+
+ Warning: +
+ +
+ + +
+ +
+
+ + +
+
+
+ +
+ + % +
+
+
+ +
+ + seconds +
+
+
+
+
+
+ + +
+
+ Intro Detection + Experimental +
+
+
+ Note: Writes "Skip Intro" markers directly to the Plex SQLite database. + Requires fpcalc (included in Docker image). + Back up your database before first use. +
+ +
+ + +
+ +
+
+ + +
+
+
+ +
+ + min +
+
+
+ +
+ + sec +
+
+
+ +
+ + sec +
+
+
+
+ Compares audio across all episodes in a season to find the recurring intro theme. + Requires at least 2 episodes per season. TV episodes only. +
+
+
+
+
@@ -594,6 +706,20 @@
Job History
document.getElementById('logRetentionCount').value = settings.log_retention_count || 5; document.getElementById('jobHistoryDays').value = settings.job_history_days ?? 30; + // Credits/intro detection + document.getElementById('creditsDetectionEnabled').checked = settings.credits_detection_enabled === true; + document.getElementById('creditsDetectionOverwrite').checked = settings.credits_detection_overwrite === true; + document.getElementById('creditsScanLastPct').value = settings.credits_scan_last_pct ?? 25; + document.getElementById('creditsMinDuration').value = settings.credits_min_duration ?? 15; + document.getElementById('introDetectionEnabled').checked = settings.intro_detection_enabled === true; + document.getElementById('introDetectionOverwrite').checked = settings.intro_detection_overwrite === true; + document.getElementById('introScanDuration').value = Math.round((settings.intro_scan_duration_sec ?? 600) / 60); + document.getElementById('introMinDuration').value = settings.intro_min_duration_sec ?? 15; + document.getElementById('introMaxDuration').value = settings.intro_max_duration_sec ?? 120; + toggleCreditsOptions(); + toggleIntroOptions(); + loadPlexDbStatus(); + // Show server name and auth status if (settings.plex_name) { document.getElementById('plexServerName').textContent = `Connected to: ${settings.plex_name}`; @@ -1094,6 +1220,16 @@
Job History
log_rotation_size: document.getElementById('logRotationSize').value + ' MB', log_retention_count: parseInt(document.getElementById('logRetentionCount').value), job_history_days: parseInt(document.getElementById('jobHistoryDays').value), + // Credits/intro detection + credits_detection_enabled: document.getElementById('creditsDetectionEnabled').checked, + credits_detection_overwrite: document.getElementById('creditsDetectionOverwrite').checked, + credits_scan_last_pct: parseFloat(document.getElementById('creditsScanLastPct').value) || 25, + credits_min_duration: parseFloat(document.getElementById('creditsMinDuration').value) || 15, + intro_detection_enabled: document.getElementById('introDetectionEnabled').checked, + intro_detection_overwrite: document.getElementById('introDetectionOverwrite').checked, + intro_scan_duration_sec: (parseInt(document.getElementById('introScanDuration').value) || 10) * 60, + intro_min_duration_sec: parseFloat(document.getElementById('introMinDuration').value) || 15, + intro_max_duration_sec: parseFloat(document.getElementById('introMaxDuration').value) || 120, }; const result = await new SettingsManager().save(settings); @@ -1198,5 +1334,27 @@
Job History
showToast('Failed to update log level: ' + error.message, 'danger'); } } + +function toggleCreditsOptions() { + const enabled = document.getElementById('creditsDetectionEnabled').checked; + document.getElementById('creditsOptions').classList.toggle('d-none', !enabled); +} + +function toggleIntroOptions() { + const enabled = document.getElementById('introDetectionEnabled').checked; + document.getElementById('introOptions').classList.toggle('d-none', !enabled); +} + +async function loadPlexDbStatus() { + try { + const data = await fetch('/api/system/plex-db-status').then(r => r.json()); + if (!data.safe && data.reason) { + document.getElementById('plexDbUnsafeWarning').classList.remove('d-none'); + document.getElementById('plexDbUnsafeReason').textContent = data.reason; + } + } catch (e) { + // Non-critical — just don't show the warning + } +} {% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py index a2966ed0..7283e6cc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -46,6 +46,16 @@ def mock_config(): config.worker_pool_timeout = 30 # None so get_library_sections filters by plex_libraries (titles), not by ID config.plex_library_ids = None + # Credits/intro detection defaults + config.credits_detection_enabled = False + config.credits_detection_overwrite = False + config.credits_scan_last_pct = 25.0 + config.credits_min_duration = 15.0 + config.intro_detection_enabled = False + config.intro_detection_overwrite = False + config.intro_scan_duration_sec = 600.0 + config.intro_min_duration_sec = 15.0 + config.intro_max_duration_sec = 120.0 return config diff --git a/tests/test_credits_detection.py b/tests/test_credits_detection.py new file mode 100644 index 00000000..2432e9cf --- /dev/null +++ b/tests/test_credits_detection.py @@ -0,0 +1,267 @@ +"""Tests for credits detection using FFmpeg blackdetect and silencedetect.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from plex_generate_previews.credits_detection import ( + BlackFrame, + CreditsDetectionConfig, + SilenceRegion, + _combine_detections, + _run_blackdetect, + _run_silencedetect, + detect_credits, +) + + +# --------------------------------------------------------------------------- +# blackdetect parsing +# --------------------------------------------------------------------------- + + +class TestRunBlackdetect: + def test_parses_black_frames(self): + stderr_output = ( + "[blackdetect @ 0x55a] black_start:10.5 black_end:12.0 black_duration:1.5\n" + "[blackdetect @ 0x55a] black_start:45.0 black_end:46.5 black_duration:1.5\n" + ) + mock_proc = MagicMock() + mock_proc.stderr = iter(stderr_output.splitlines(keepends=True)) + mock_proc.wait.return_value = 0 + + with patch("plex_generate_previews.credits_detection.subprocess.Popen") as mock: + mock.return_value = mock_proc + frames = _run_blackdetect("video.mp4", seek_to=100.0) + + assert len(frames) == 2 + # Timestamps should be offset by seek_to + assert frames[0].start == pytest.approx(110.5) + assert frames[0].end == pytest.approx(112.0) + assert frames[0].duration == pytest.approx(1.5) + assert frames[1].start == pytest.approx(145.0) + + def test_returns_empty_on_no_output(self): + mock_proc = MagicMock() + mock_proc.stderr = iter(["frame=100 fps=30\n"]) + mock_proc.wait.return_value = 0 + + with patch("plex_generate_previews.credits_detection.subprocess.Popen") as mock: + mock.return_value = mock_proc + frames = _run_blackdetect("video.mp4", seek_to=0) + + assert frames == [] + + def test_cancellation_stops_processing(self): + stderr_output = ( + "[blackdetect @ 0x55a] black_start:10.0 black_end:11.0 black_duration:1.0\n" + "[blackdetect @ 0x55a] black_start:20.0 black_end:21.0 black_duration:1.0\n" + ) + mock_proc = MagicMock() + mock_proc.stderr = iter(stderr_output.splitlines(keepends=True)) + mock_proc.wait.return_value = -9 + + cancel = MagicMock(side_effect=[False, True]) + + with patch("plex_generate_previews.credits_detection.subprocess.Popen") as mock: + mock.return_value = mock_proc + frames = _run_blackdetect("video.mp4", seek_to=0, cancel_check=cancel) + + # Should have parsed first line, cancelled on second + assert len(frames) <= 2 + mock_proc.kill.assert_called_once() + + +# --------------------------------------------------------------------------- +# silencedetect parsing +# --------------------------------------------------------------------------- + + +class TestRunSilencedetect: + def test_parses_silence_regions(self): + stderr_output = ( + "[silencedetect @ 0x55b] silence_start: 50.2\n" + "[silencedetect @ 0x55b] silence_end: 53.7 | silence_duration: 3.5\n" + "[silencedetect @ 0x55b] silence_start: 80.0\n" + "[silencedetect @ 0x55b] silence_end: 85.0 | silence_duration: 5.0\n" + ) + mock_proc = MagicMock() + mock_proc.stderr = iter(stderr_output.splitlines(keepends=True)) + mock_proc.wait.return_value = 0 + + with patch("plex_generate_previews.credits_detection.subprocess.Popen") as mock: + mock.return_value = mock_proc + regions = _run_silencedetect("video.mp4", seek_to=200.0) + + assert len(regions) == 2 + # Timestamps offset by seek_to + assert regions[0].start == pytest.approx(250.2) + assert regions[0].end == pytest.approx(253.7) + assert regions[0].duration == pytest.approx(3.5) + assert regions[1].start == pytest.approx(280.0) + + def test_returns_empty_on_no_silence(self): + mock_proc = MagicMock() + mock_proc.stderr = iter(["size=0kB time=01:00:00.00\n"]) + mock_proc.wait.return_value = 0 + + with patch("plex_generate_previews.credits_detection.subprocess.Popen") as mock: + mock.return_value = mock_proc + regions = _run_silencedetect("video.mp4", seek_to=0) + + assert regions == [] + + +# --------------------------------------------------------------------------- +# _combine_detections +# --------------------------------------------------------------------------- + + +class TestCombineDetections: + def test_combined_black_and_silence(self): + """Overlapping black+silence near end → high confidence.""" + black = [BlackFrame(start=1800.0, end=1801.0, duration=1.0)] + silence = [SilenceRegion(start=1799.5, end=1802.0, duration=2.5)] + + result = _combine_detections( + black, silence, total_duration_sec=2000.0, min_credits_duration_sec=15.0 + ) + assert result is not None + assert result.method == "black+silence" + assert result.confidence == 0.9 + assert result.start_ms == int(1799.5 * 1000) + assert result.end_ms == 2000000 + + def test_black_only_fallback(self): + """Black frames without silence → medium confidence.""" + black = [BlackFrame(start=1800.0, end=1801.0, duration=1.0)] + + result = _combine_detections( + black, [], total_duration_sec=2000.0, min_credits_duration_sec=15.0 + ) + assert result is not None + assert result.method == "black_only" + assert result.confidence == 0.6 + + def test_silence_only_fallback(self): + """Silence without black frames → lower confidence.""" + silence = [SilenceRegion(start=1800.0, end=1805.0, duration=5.0)] + + result = _combine_detections( + [], silence, total_duration_sec=2000.0, min_credits_duration_sec=15.0 + ) + assert result is not None + assert result.method == "silence_only" + assert result.confidence == 0.4 + + def test_rejects_credits_too_early(self): + """Detections before 75% of the video are rejected.""" + black = [BlackFrame(start=500.0, end=501.0, duration=1.0)] + silence = [SilenceRegion(start=500.0, end=505.0, duration=5.0)] + + result = _combine_detections( + black, silence, total_duration_sec=2000.0, max_credits_start_pct=75.0 + ) + assert result is None + + def test_rejects_credits_too_short(self): + """Credits segment shorter than minimum duration is rejected.""" + black = [BlackFrame(start=1995.0, end=1996.0, duration=1.0)] + silence = [SilenceRegion(start=1995.0, end=1998.0, duration=3.0)] + + result = _combine_detections( + black, silence, total_duration_sec=2000.0, min_credits_duration_sec=15.0 + ) + assert result is None + + def test_no_detections_returns_none(self): + result = _combine_detections([], [], total_duration_sec=2000.0) + assert result is None + + def test_picks_earliest_qualifying_region(self): + """When multiple regions qualify, pick the earliest.""" + black = [ + BlackFrame(start=1700.0, end=1701.0, duration=1.0), + BlackFrame(start=1900.0, end=1901.0, duration=1.0), + ] + silence = [ + SilenceRegion(start=1700.0, end=1705.0, duration=5.0), + SilenceRegion(start=1900.0, end=1905.0, duration=5.0), + ] + + result = _combine_detections( + black, + silence, + total_duration_sec=2000.0, + min_credits_duration_sec=15.0, + max_credits_start_pct=75.0, + ) + assert result is not None + assert result.start_ms == int(1700.0 * 1000) + + def test_adjacent_regions_combine(self): + """Black and silence regions within tolerance combine.""" + black = [BlackFrame(start=1800.0, end=1801.0, duration=1.0)] + silence = [SilenceRegion(start=1803.0, end=1807.0, duration=4.0)] + + result = _combine_detections( + black, silence, total_duration_sec=2000.0, min_credits_duration_sec=15.0 + ) + assert result is not None + assert result.method == "black+silence" + # Credits start at the earlier of the two + assert result.start_ms == int(1800.0 * 1000) + + +# --------------------------------------------------------------------------- +# detect_credits (end-to-end with mocked subprocess) +# --------------------------------------------------------------------------- + + +class TestDetectCredits: + def test_returns_none_for_short_video(self): + result = detect_credits("video.mp4", total_duration_sec=10.0) + assert result is None + + @patch("plex_generate_previews.credits_detection._run_silencedetect") + @patch("plex_generate_previews.credits_detection._run_blackdetect") + def test_end_to_end_detection(self, mock_black, mock_silence): + mock_black.return_value = [ + BlackFrame(start=2700.0, end=2701.0, duration=1.0), + ] + mock_silence.return_value = [ + SilenceRegion(start=2699.0, end=2703.0, duration=4.0), + ] + + result = detect_credits("video.mp4", total_duration_sec=3600.0) + assert result is not None + assert result.method == "black+silence" + assert result.start_ms == int(2699.0 * 1000) + + @patch("plex_generate_previews.credits_detection._run_silencedetect") + @patch("plex_generate_previews.credits_detection._run_blackdetect") + def test_respects_cancellation(self, mock_black, mock_silence): + mock_black.return_value = [] + + result = detect_credits( + "video.mp4", + total_duration_sec=3600.0, + cancel_check=lambda: True, + ) + assert result is None + + @patch("plex_generate_previews.credits_detection._run_silencedetect") + @patch("plex_generate_previews.credits_detection._run_blackdetect") + def test_custom_config(self, mock_black, mock_silence): + mock_black.return_value = [] + mock_silence.return_value = [] + + config = CreditsDetectionConfig( + scan_last_pct=10.0, + min_credits_duration=30.0, + ) + detect_credits("video.mp4", total_duration_sec=3600.0, config=config) + + # Verify seek_to was computed from custom scan_last_pct + call_args = mock_black.call_args + assert call_args[0][1] == pytest.approx(3240.0) # 3600 * 0.90 diff --git a/tests/test_intro_detection.py b/tests/test_intro_detection.py new file mode 100644 index 00000000..344d4765 --- /dev/null +++ b/tests/test_intro_detection.py @@ -0,0 +1,312 @@ +"""Tests for intro detection using chromaprint fingerprinting.""" + +import random +from unittest.mock import MagicMock, patch + + +from plex_generate_previews.intro_detection import ( + IntroFingerprintStore, + MatchRegion, + _compare_fingerprints, + _find_best_common_segment, + _popcount, + _run_fpcalc, + check_fpcalc_available, + find_common_intro, + fingerprint_episode, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_fingerprint(length: int, seed: int = 42) -> list[int]: + """Generate a deterministic random fingerprint.""" + rng = random.Random(seed) + return [rng.randint(0, 0xFFFFFFFF) for _ in range(length)] + + +def _make_matching_pair( + total_length: int = 500, + intro_length: int = 100, + intro_offset_a: int = 10, + intro_offset_b: int = 20, + seed: int = 42, +) -> tuple[list[int], list[int]]: + """Create two fingerprints with a shared intro segment. + + The intro segment is identical in both fingerprints but at + different offsets. The rest is random noise. + """ + rng = random.Random(seed) + # Shared intro segment + intro = [rng.randint(0, 0xFFFFFFFF) for _ in range(intro_length)] + + # Build fp1: noise + intro at offset_a + noise + fp1 = [rng.randint(0, 0xFFFFFFFF) for _ in range(total_length)] + fp1[intro_offset_a : intro_offset_a + intro_length] = intro + + # Build fp2: noise + intro at offset_b + noise (different seed for noise) + rng2 = random.Random(seed + 999) + fp2 = [rng2.randint(0, 0xFFFFFFFF) for _ in range(total_length)] + fp2[intro_offset_b : intro_offset_b + intro_length] = intro + + return fp1, fp2 + + +# --------------------------------------------------------------------------- +# popcount +# --------------------------------------------------------------------------- + + +class TestPopcount: + def test_zero(self): + assert _popcount(0) == 0 + + def test_all_ones(self): + assert _popcount(0xFFFFFFFF) == 32 + + def test_specific_value(self): + assert _popcount(0b10101010) == 4 + + +# --------------------------------------------------------------------------- +# fpcalc parsing +# --------------------------------------------------------------------------- + + +class TestRunFpcalc: + def test_parses_fingerprint(self): + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "DURATION=120\nFINGERPRINT=100,200,300,400\n" + mock_result.stderr = "" + + with patch( + "plex_generate_previews.intro_detection.subprocess.run", + return_value=mock_result, + ): + fp = _run_fpcalc("video.mp4", length_sec=120) + + assert fp == [100, 200, 300, 400] + + def test_returns_none_on_error(self): + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stderr = "error: cannot open file" + + with patch( + "plex_generate_previews.intro_detection.subprocess.run", + return_value=mock_result, + ): + fp = _run_fpcalc("video.mp4", length_sec=120) + + assert fp is None + + def test_returns_none_when_not_installed(self): + with patch( + "plex_generate_previews.intro_detection.subprocess.run", + side_effect=FileNotFoundError, + ): + fp = _run_fpcalc("video.mp4", length_sec=120) + + assert fp is None + + +# --------------------------------------------------------------------------- +# fingerprint_episode +# --------------------------------------------------------------------------- + + +class TestFingerprintEpisode: + def test_respects_cancellation(self): + result = fingerprint_episode("video.mp4", cancel_check=lambda: True) + assert result is None + + +# --------------------------------------------------------------------------- +# check_fpcalc_available +# --------------------------------------------------------------------------- + + +class TestCheckFpcalcAvailable: + def test_available(self): + with patch( + "plex_generate_previews.intro_detection.shutil.which", + return_value="/usr/bin/fpcalc", + ): + assert check_fpcalc_available() is True + + def test_not_available(self): + with patch( + "plex_generate_previews.intro_detection.shutil.which", + return_value=None, + ): + assert check_fpcalc_available() is False + + +# --------------------------------------------------------------------------- +# _compare_fingerprints +# --------------------------------------------------------------------------- + + +class TestCompareFingerprints: + def test_identical_fingerprints_match(self): + fp = _make_fingerprint(200) + regions = _compare_fingerprints(fp, fp, min_run_length=50) + assert len(regions) > 0 + # Should find a long match at offset 0 + best = max(regions, key=lambda r: r.length) + assert best.length >= 100 + + def test_no_match_for_random_fingerprints(self): + fp1 = _make_fingerprint(200, seed=1) + fp2 = _make_fingerprint(200, seed=2) + regions = _compare_fingerprints(fp1, fp2, min_run_length=50) + assert len(regions) == 0 + + def test_finds_shared_intro_segment(self): + fp1, fp2 = _make_matching_pair( + total_length=500, + intro_length=120, + intro_offset_a=10, + intro_offset_b=20, + ) + regions = _compare_fingerprints(fp1, fp2, min_run_length=50) + assert len(regions) > 0 + best = max(regions, key=lambda r: r.length) + assert best.length >= 100 + assert best.score > 0.9 + + +# --------------------------------------------------------------------------- +# _find_best_common_segment +# --------------------------------------------------------------------------- + + +class TestFindBestCommonSegment: + def test_selects_best_by_score_and_duration(self): + matches = [ + MatchRegion(offset_a=10, offset_b=20, length=200, score=0.95), + MatchRegion(offset_a=50, offset_b=60, length=100, score=0.80), + ] + result = _find_best_common_segment(matches, min_duration_sec=5.0) + assert result is not None + assert result.confidence == 0.95 + + def test_rejects_too_short(self): + matches = [ + MatchRegion(offset_a=10, offset_b=20, length=10, score=0.95), + ] + result = _find_best_common_segment(matches, min_duration_sec=15.0) + assert result is None + + def test_rejects_too_long(self): + matches = [ + MatchRegion(offset_a=0, offset_b=0, length=10000, score=0.95), + ] + result = _find_best_common_segment( + matches, min_duration_sec=15.0, max_duration_sec=120.0 + ) + assert result is None + + def test_returns_none_for_empty(self): + assert _find_best_common_segment([]) is None + + +# --------------------------------------------------------------------------- +# IntroFingerprintStore +# --------------------------------------------------------------------------- + + +class TestIntroFingerprintStore: + def test_add_and_retrieve(self): + store = IntroFingerprintStore() + store.add("Breaking Bad", 1, 100, [1, 2, 3]) + store.add("Breaking Bad", 1, 101, [4, 5, 6]) + store.add("Breaking Bad", 2, 200, [7, 8, 9]) + + seasons = store.get_seasons() + # Season 2 only has 1 episode, so it's excluded + assert len(seasons) == 1 + show, season, episodes = seasons[0] + assert show == "Breaking Bad" + assert season == 1 + assert len(episodes) == 2 + + def test_requires_minimum_two_episodes(self): + store = IntroFingerprintStore() + store.add("Show", 1, 100, [1, 2, 3]) + assert store.get_seasons() == [] + + def test_bool_and_len(self): + store = IntroFingerprintStore() + assert not store + assert len(store) == 0 + + store.add("Show", 1, 100, [1, 2, 3]) + assert store + assert len(store) == 1 + + def test_thread_safety(self): + """Concurrent adds don't corrupt the store.""" + import threading + + store = IntroFingerprintStore() + errors = [] + + def add_many(show_idx): + try: + for ep in range(50): + store.add(f"Show{show_idx}", 1, ep, [ep] * 10) + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=add_many, args=(i,)) for i in range(5)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors + assert len(store) == 250 # 5 shows * 50 episodes + + +# --------------------------------------------------------------------------- +# find_common_intro +# --------------------------------------------------------------------------- + + +class TestFindCommonIntro: + def test_finds_common_intro_in_matching_episodes(self): + fp1, fp2 = _make_matching_pair( + total_length=500, + intro_length=150, # ~18.5 seconds + intro_offset_a=10, + intro_offset_b=15, + ) + result = find_common_intro( + [(1, fp1), (2, fp2)], + min_duration_sec=5.0, + max_duration_sec=120.0, + ) + assert result is not None + assert result.confidence > 0.5 + assert result.start_ms >= 0 + assert result.end_ms > result.start_ms + + def test_returns_none_for_single_episode(self): + fp = _make_fingerprint(200) + result = find_common_intro([(1, fp)]) + assert result is None + + def test_returns_none_for_unrelated_episodes(self): + fp1 = _make_fingerprint(200, seed=1) + fp2 = _make_fingerprint(200, seed=2) + result = find_common_intro( + [(1, fp1), (2, fp2)], + min_duration_sec=5.0, + ) + assert result is None diff --git a/tests/test_plex_db.py b/tests/test_plex_db.py new file mode 100644 index 00000000..04be8a9c --- /dev/null +++ b/tests/test_plex_db.py @@ -0,0 +1,348 @@ +"""Tests for the Plex SQLite database access layer. + +Validates safe read/write access to marker data in the Plex database, +connection discipline (busy_timeout, immediate close), and environment +safety checks. +""" + +import os +import sqlite3 +from unittest.mock import patch + +import pytest + +from plex_generate_previews.plex_db import ( + _EXTRA_DATA_INTRO, + _MARKER_TAG_TYPE, + check_db_write_safety, + delete_markers, + get_existing_markers, + get_marker_tag_id, + get_plex_db_path, + write_marker, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _create_mock_plex_db(db_path: str, marker_tag_id: int = 30569) -> None: + """Create a minimal Plex database with tags and taggings tables.""" + conn = sqlite3.connect(db_path) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + metadata_item_id INTEGER, + tag VARCHAR(255), + tag_type INTEGER, + user_thumb_url VARCHAR(255), + user_art_url VARCHAR(255), + user_music_url VARCHAR(255), + created_at INTEGER, + updated_at INTEGER, + tag_value INTEGER, + extra_data VARCHAR(255), + key VARCHAR(255), + parent_id INTEGER + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS taggings ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + metadata_item_id INTEGER, + tag_id INTEGER, + "index" INTEGER, + text VARCHAR(255), + time_offset INTEGER, + end_time_offset INTEGER, + thumb_url VARCHAR(255), + created_at INTEGER, + extra_data VARCHAR(255) + ) + """ + ) + # Insert the marker tag (tag_type=12) + conn.execute( + "INSERT INTO tags (id, tag_type) VALUES (?, ?)", + (marker_tag_id, _MARKER_TAG_TYPE), + ) + conn.commit() + conn.close() + + +@pytest.fixture() +def plex_db(tmp_path): + """Create a mock Plex database and return its path.""" + db_path = str(tmp_path / "com.plexapp.plugins.library.db") + _create_mock_plex_db(db_path) + return db_path + + +@pytest.fixture() +def plex_config_folder(tmp_path): + """Create a mock Plex config folder structure with a database.""" + db_dir = tmp_path / "Plug-in Support" / "Databases" + db_dir.mkdir(parents=True) + db_path = db_dir / "com.plexapp.plugins.library.db" + _create_mock_plex_db(str(db_path)) + return str(tmp_path) + + +# --------------------------------------------------------------------------- +# get_plex_db_path +# --------------------------------------------------------------------------- + + +class TestGetPlexDbPath: + def test_finds_database_direct_mount(self, plex_config_folder): + result = get_plex_db_path(plex_config_folder) + assert result.endswith("com.plexapp.plugins.library.db") + assert os.path.isfile(result) + + def test_finds_database_nested_layout(self, tmp_path): + """Find DB in Library/Application Support/Plex Media Server layout.""" + nested = ( + tmp_path + / "Library" + / "Application Support" + / "Plex Media Server" + / "Plug-in Support" + / "Databases" + ) + nested.mkdir(parents=True) + db_path = nested / "com.plexapp.plugins.library.db" + _create_mock_plex_db(str(db_path)) + + result = get_plex_db_path(str(tmp_path)) + assert os.path.isfile(result) + + def test_raises_when_not_found(self, tmp_path): + with pytest.raises(FileNotFoundError, match="Plex database not found"): + get_plex_db_path(str(tmp_path)) + + +# --------------------------------------------------------------------------- +# check_db_write_safety +# --------------------------------------------------------------------------- + + +class TestCheckDbWriteSafety: + def test_safe_on_local_writable_file(self, plex_db): + safe, reason = check_db_write_safety(plex_db) + assert safe is True + assert reason == "" + + def test_unsafe_when_file_missing(self, tmp_path): + safe, reason = check_db_write_safety(str(tmp_path / "nonexistent.db")) + assert safe is False + assert "not found" in reason + + def test_unsafe_when_not_writable(self, plex_db): + os.chmod(plex_db, 0o444) + try: + safe, reason = check_db_write_safety(plex_db) + assert safe is False + assert "not writable" in reason + finally: + os.chmod(plex_db, 0o644) + + def test_unsafe_when_flock_fails(self, plex_db): + """Simulate Docker-on-Windows by making flock raise OSError.""" + import fcntl as real_fcntl + + def flock_raises(*args, **kwargs): + raise OSError("Operation not supported") + + with patch.object(real_fcntl, "flock", side_effect=flock_raises): + safe, reason = check_db_write_safety(plex_db) + assert safe is False + assert "locking" in reason.lower() + + +# --------------------------------------------------------------------------- +# get_marker_tag_id +# --------------------------------------------------------------------------- + + +class TestGetMarkerTagId: + def test_returns_correct_tag_id(self, plex_db): + tag_id = get_marker_tag_id(plex_db) + assert tag_id == 30569 + + def test_raises_when_no_marker_tag(self, tmp_path): + db_path = str(tmp_path / "empty.db") + conn = sqlite3.connect(db_path) + conn.execute("CREATE TABLE tags (id INTEGER PRIMARY KEY, tag_type INTEGER)") + conn.commit() + conn.close() + + with pytest.raises(RuntimeError, match="No marker tag"): + get_marker_tag_id(db_path) + + +# --------------------------------------------------------------------------- +# write_marker +# --------------------------------------------------------------------------- + + +class TestWriteMarker: + def test_write_credits_marker(self, plex_db): + success = write_marker( + plex_db, + metadata_item_id=12345, + tag_id=30569, + marker_type="credits", + start_ms=1200000, + end_ms=1350000, + is_final=True, + ) + assert success is True + + # Verify the written data + conn = sqlite3.connect(plex_db) + conn.row_factory = sqlite3.Row + row = conn.execute( + "SELECT * FROM taggings WHERE metadata_item_id = 12345" + ).fetchone() + conn.close() + + assert row["text"] == "credits" + assert row["time_offset"] == 1200000 + assert row["end_time_offset"] == 1350000 + assert row["tag_id"] == 30569 + assert row["index"] == 0 + assert "pv:final" in row["extra_data"] + assert row["created_at"] > 0 + + def test_write_intro_marker(self, plex_db): + success = write_marker( + plex_db, + metadata_item_id=12345, + tag_id=30569, + marker_type="intro", + start_ms=5000, + end_ms=65000, + ) + assert success is True + + conn = sqlite3.connect(plex_db) + conn.row_factory = sqlite3.Row + row = conn.execute( + "SELECT * FROM taggings WHERE metadata_item_id = 12345" + ).fetchone() + conn.close() + + assert row["text"] == "intro" + assert row["extra_data"] == _EXTRA_DATA_INTRO + + def test_auto_increments_index(self, plex_db): + """Multiple markers on the same item get sequential indices.""" + write_marker(plex_db, 100, 30569, "credits", 50000, 60000) + write_marker(plex_db, 100, 30569, "credits", 60000, 70000) + + conn = sqlite3.connect(plex_db) + rows = conn.execute( + 'SELECT "index" FROM taggings WHERE metadata_item_id = 100 ORDER BY "index"' + ).fetchall() + conn.close() + + assert [r[0] for r in rows] == [0, 1] + + def test_rejects_invalid_marker_type(self, plex_db): + with pytest.raises(ValueError, match="marker_type"): + write_marker(plex_db, 100, 30569, "commercial", 0, 1000) + + def test_credits_non_final_extra_data(self, plex_db): + write_marker(plex_db, 100, 30569, "credits", 50000, 60000, is_final=False) + + conn = sqlite3.connect(plex_db) + conn.row_factory = sqlite3.Row + row = conn.execute( + "SELECT extra_data FROM taggings WHERE metadata_item_id = 100" + ).fetchone() + conn.close() + + assert "pv:final" not in row["extra_data"] + assert "pv:version" in row["extra_data"] + + +# --------------------------------------------------------------------------- +# delete_markers +# --------------------------------------------------------------------------- + + +class TestDeleteMarkers: + def test_deletes_markers_by_type(self, plex_db): + write_marker(plex_db, 100, 30569, "credits", 50000, 60000) + write_marker(plex_db, 100, 30569, "intro", 5000, 35000) + + deleted = delete_markers(plex_db, 100, 30569, "credits") + assert deleted == 1 + + # Intro should still exist + markers = get_existing_markers(plex_db, 100, 30569) + assert len(markers) == 1 + assert markers[0]["type"] == "intro" + + def test_returns_zero_when_nothing_to_delete(self, plex_db): + deleted = delete_markers(plex_db, 999, 30569, "credits") + assert deleted == 0 + + +# --------------------------------------------------------------------------- +# get_existing_markers +# --------------------------------------------------------------------------- + + +class TestGetExistingMarkers: + def test_returns_markers_ordered_by_index(self, plex_db): + write_marker(plex_db, 100, 30569, "intro", 5000, 35000) + write_marker(plex_db, 100, 30569, "credits", 1200000, 1350000) + + markers = get_existing_markers(plex_db, 100, 30569) + assert len(markers) == 2 + assert markers[0]["type"] == "intro" + assert markers[0]["start_ms"] == 5000 + assert markers[1]["type"] == "credits" + assert markers[1]["start_ms"] == 1200000 + + def test_returns_empty_for_no_markers(self, plex_db): + markers = get_existing_markers(plex_db, 999, 30569) + assert markers == [] + + +# --------------------------------------------------------------------------- +# Connection discipline +# --------------------------------------------------------------------------- + + +class TestConnectionDiscipline: + def test_busy_timeout_is_set(self, plex_db): + """Verify PRAGMA busy_timeout is configured on connections.""" + from plex_generate_previews.plex_db import _connect + + conn = _connect(plex_db) + try: + row = conn.execute("PRAGMA busy_timeout").fetchone() + assert row[0] == 5000 + finally: + conn.close() + + def test_no_insert_into_tags_table(self, plex_db): + """Verify that get_marker_tag_id only reads, never writes to tags.""" + conn = sqlite3.connect(plex_db) + count_before = conn.execute("SELECT COUNT(*) FROM tags").fetchone()[0] + conn.close() + + get_marker_tag_id(plex_db) + + conn = sqlite3.connect(plex_db) + count_after = conn.execute("SELECT COUNT(*) FROM tags").fetchone()[0] + conn.close() + + assert count_after == count_before From 81fcc7f2f709f5da14f3614adaa9bd63c1e176a1 Mon Sep 17 00:00:00 2001 From: "Adam J. Weigold" Date: Mon, 23 Mar 2026 13:09:00 -0500 Subject: [PATCH 2/3] refactor: address PR #191 review feedback Critical: - Thread fingerprint_store explicitly through worker pipeline instead of monkey-patching config._fingerprint_store (#1) - Multi-reference intro comparison: compare multiple episode pairs instead of only episode[0] vs all others (#2) UX/Logging: - Promote detection log messages from debug to info: credits scan start, no credits found, marker exists, fpcalc not available (#3, #6) - Intro pass 2 progress visibility: emit progress callback during season comparison so dashboard doesn't look stuck at 100% (#4) - Add detection_stats tracking for credits_written/skipped counters (#5) Significant: - Server-side validation: clamp numeric detection settings to valid ranges in job_runner.py (#7) - Deduplicate plex.fetchItem call in intro fingerprinting (#8) - Skip credits markers with confidence < 0.5 to reduce false positives from silence-only detection (#9) Minor: - Deterministic ORDER BY id in get_marker_tag_id query (#10) - Use apiGet for loadPlexDbStatus consistency (#11) - Add complexity comment on fingerprint comparison (#12) Co-Authored-By: Claude Opus 4.6 (1M context) --- plex_generate_previews/credits_detection.py | 4 +- plex_generate_previews/intro_detection.py | 32 +++++++--- plex_generate_previews/job_dispatcher.py | 2 + plex_generate_previews/media_processing.py | 62 ++++++++++++------- plex_generate_previews/plex_db.py | 2 +- plex_generate_previews/processing.py | 19 ++++-- .../web/routes/job_runner.py | 21 ++++--- .../web/templates/settings.html | 2 +- plex_generate_previews/worker.py | 14 ++++- tests/test_worker.py | 4 ++ tests/test_worker_concurrency.py | 6 ++ 11 files changed, 119 insertions(+), 49 deletions(-) diff --git a/plex_generate_previews/credits_detection.py b/plex_generate_previews/credits_detection.py index c3a386a9..c2a484cc 100644 --- a/plex_generate_previews/credits_detection.py +++ b/plex_generate_previews/credits_detection.py @@ -345,8 +345,8 @@ def detect_credits( # Scan the last N% of the video seek_to = total_duration_sec * (1.0 - config.scan_last_pct / 100.0) - logger.debug( - f"Credits detection: scanning from {seek_to:.0f}s " + logger.info( + f"Credits scan: scanning from {seek_to:.0f}s " f"(last {config.scan_last_pct}% of {total_duration_sec:.0f}s)" ) diff --git a/plex_generate_previews/intro_detection.py b/plex_generate_previews/intro_detection.py index 3602cf1e..7b3cd1ac 100644 --- a/plex_generate_previews/intro_detection.py +++ b/plex_generate_previews/intro_detection.py @@ -222,6 +222,10 @@ def _compare_fingerprints( 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. @@ -352,21 +356,35 @@ def find_common_intro( if len(fingerprints) < 2: return None - all_matches: List[MatchRegion] = [] + # 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)) - # Pairwise comparison (first episode compared to all others) - base_key, base_fp = fingerprints[0] - for other_key, other_fp in fingerprints[1:]: - matches = _compare_fingerprints(base_fp, other_fp) + 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.debug("No matching audio segments found across episodes") + 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.debug( + logger.info( f"Found common intro: {result.start_ms}ms–{result.end_ms}ms " f"(confidence: {result.confidence:.0%})" ) diff --git a/plex_generate_previews/job_dispatcher.py b/plex_generate_previews/job_dispatcher.py index a40d9011..2031fd05 100644 --- a/plex_generate_previews/job_dispatcher.py +++ b/plex_generate_previews/job_dispatcher.py @@ -85,6 +85,7 @@ def __init__( self.on_item_complete: Optional[Callable] = cbs.get("on_item_complete") self.cancel_check: Optional[Callable] = cbs.get("cancel_check") self.pause_check: Optional[Callable] = cbs.get("pause_check") + self.fingerprint_store = cbs.get("fingerprint_store") # Throttle timestamps for callbacks self._last_progress_update = 0.0 @@ -456,6 +457,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]}) " diff --git a/plex_generate_previews/media_processing.py b/plex_generate_previews/media_processing.py index 3da7c43d..469e88ec 100644 --- a/plex_generate_previews/media_processing.py +++ b/plex_generate_previews/media_processing.py @@ -1758,9 +1758,7 @@ def process_item( _video_el = data.find(".//Video") _media_type = _video_el.attrib.get("type", "") if _video_el is not None else "" - # Resolve fingerprint store (passed directly or via config) - if fingerprint_store is None: - fingerprint_store = getattr(config, "_fingerprint_store", None) + # fingerprint_store is passed explicitly through the worker pipeline def _update_best(result: ProcessingResult) -> None: nonlocal best_result @@ -1956,6 +1954,7 @@ def _run_marker_detection( config: Config, cancel_check=None, fingerprint_store=None, + detection_stats: dict = None, ) -> None: """Run credits detection and/or intro fingerprinting for a media item. @@ -1979,7 +1978,9 @@ def _run_marker_detection( # Credits detection (movies + episodes) if config.credits_detection_enabled: try: - _detect_and_write_credits(item_key, media_file, plex, config, cancel_check) + _detect_and_write_credits( + item_key, media_file, plex, config, cancel_check, detection_stats + ) except CancellationError: raise except Exception as exc: @@ -2007,6 +2008,7 @@ def _detect_and_write_credits( plex, config: Config, cancel_check=None, + detection_stats: dict = None, ) -> None: """Detect credits and write marker to Plex database. @@ -2028,7 +2030,11 @@ def _detect_and_write_credits( try: item = retry_plex_call(plex.fetchItem, rating_key) if getattr(item, "hasCreditsMarker", False): - logger.debug(f"Credits marker exists for {media_file}, skipping") + 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}") @@ -2075,7 +2081,15 @@ def _detect_and_write_credits( ) if segment is None: - logger.debug(f"No credits detected in {media_file}") + 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} " + f"({segment.confidence:.0%} < 50%)" + ) return tag_id = get_marker_tag_id(db_path) @@ -2095,6 +2109,10 @@ def _detect_and_write_credits( ) 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 " @@ -2118,33 +2136,31 @@ def _fingerprint_for_intro( from .intro_detection import check_fpcalc_available, fingerprint_episode if not check_fpcalc_available(): - logger.debug("fpcalc not available, skipping intro fingerprinting") + logger.info("fpcalc not available, skipping intro fingerprinting") return rating_key = int(item_key.rstrip("/").split("/")[-1]) - # Check existing markers - if not config.intro_detection_overwrite: - try: - item = retry_plex_call(plex.fetchItem, rating_key) - if getattr(item, "hasIntroMarker", False): - logger.debug(f"Intro marker exists for {media_file}, skipping") - return - except Exception: - pass - - # Get show title and season number for grouping + # Single fetch for both marker check and metadata extraction try: item = retry_plex_call(plex.fetchItem, rating_key) - 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 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, diff --git a/plex_generate_previews/plex_db.py b/plex_generate_previews/plex_db.py index 1e011653..67828129 100644 --- a/plex_generate_previews/plex_db.py +++ b/plex_generate_previews/plex_db.py @@ -173,7 +173,7 @@ def get_marker_tag_id(db_path: str) -> int: conn = _connect(db_path) try: row = conn.execute( - "SELECT id FROM tags WHERE tag_type = ? LIMIT 1", + "SELECT id FROM tags WHERE tag_type = ? ORDER BY id LIMIT 1", (_MARKER_TAG_TYPE,), ).fetchone() if row is None: diff --git a/plex_generate_previews/processing.py b/plex_generate_previews/processing.py index da672052..957e4740 100644 --- a/plex_generate_previews/processing.py +++ b/plex_generate_previews/processing.py @@ -97,13 +97,12 @@ def _merge_outcome(result_dict): logger.info("Running in headless mode (no console display)") # Create fingerprint store for intro detection pass 1. - # Attached to config so it flows through the worker pipeline. + # 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() - config._fingerprint_store = fingerprint_store _dispatch_started = False @@ -144,6 +143,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 @@ -251,7 +251,16 @@ def _dispatch_items(items, library_name): # Intro detection pass 2: compare fingerprints within each season if fingerprint_store and not cancellation_requested: - _process_intro_fingerprints(fingerprint_store, config, plex, cancel_check) + 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) @@ -335,7 +344,9 @@ def _dispatch_items(items, library_name): ) -def _process_intro_fingerprints(fingerprint_store, config, plex, cancel_check=None): +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 diff --git a/plex_generate_previews/web/routes/job_runner.py b/plex_generate_previews/web/routes/job_runner.py index 3eb16d67..653c1f35 100644 --- a/plex_generate_previews/web/routes/job_runner.py +++ b/plex_generate_previews/web/routes/job_runner.py @@ -230,11 +230,11 @@ def job_thread_filter(record: dict) -> bool: config.credits_detection_overwrite = bool( settings.get("credits_detection_overwrite", False) ) - config.credits_scan_last_pct = float( - settings.get("credits_scan_last_pct", 25.0) + config.credits_scan_last_pct = max( + 5.0, min(50.0, float(settings.get("credits_scan_last_pct", 25.0))) ) - config.credits_min_duration = float( - settings.get("credits_min_duration", 15.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) @@ -242,14 +242,15 @@ def job_thread_filter(record: dict) -> bool: config.intro_detection_overwrite = bool( settings.get("intro_detection_overwrite", False) ) - config.intro_scan_duration_sec = float( - settings.get("intro_scan_duration_sec", 600.0) + 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 = float( - settings.get("intro_min_duration_sec", 15.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 = float( - settings.get("intro_max_duration_sec", 120.0) + config.intro_max_duration_sec = max( + 30.0, min(300.0, float(settings.get("intro_max_duration_sec", 120.0))) ) if config_overrides: diff --git a/plex_generate_previews/web/templates/settings.html b/plex_generate_previews/web/templates/settings.html index 2485a093..4902c3a4 100644 --- a/plex_generate_previews/web/templates/settings.html +++ b/plex_generate_previews/web/templates/settings.html @@ -1347,7 +1347,7 @@
Job History
async function loadPlexDbStatus() { try { - const data = await fetch('/api/system/plex-db-status').then(r => r.json()); + const data = await apiGet('/api/system/plex-db-status'); if (!data.safe && data.reason) { document.getElementById('plexDbUnsafeWarning').classList.remove('d-none'); document.getElementById('plexDbUnsafeReason').textContent = data.reason; diff --git a/plex_generate_previews/worker.py b/plex_generate_previews/worker.py index 3eac22c6..d061db5d 100644 --- a/plex_generate_previews/worker.py +++ b/plex_generate_previews/worker.py @@ -221,6 +221,7 @@ def assign_task( job_id: Optional[str] = None, library_name: str = "", cancel_check=None, + fingerprint_store=None, ) -> None: """Assign a new task to this worker. @@ -236,6 +237,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: @@ -291,7 +293,14 @@ def assign_task( # Start processing in background thread self.current_thread = threading.Thread( target=self._process_item, - args=(item_key, config, plex, progress_callback, cpu_fallback_queue), + args=( + item_key, + config, + plex, + progress_callback, + cpu_fallback_queue, + fingerprint_store, + ), daemon=True, ) self.current_thread.start() @@ -303,6 +312,7 @@ def _process_item( plex, progress_callback=None, cpu_fallback_queue=None, + fingerprint_store=None, ) -> None: """Process a media item in the background thread. @@ -312,6 +322,7 @@ def _process_item( plex: Plex server instance progress_callback: Callback function for progress updates cpu_fallback_queue: Optional queue to add task to if codec error occurs (GPU workers only) + fingerprint_store: Optional IntroFingerprintStore for intro detection """ register_job_thread() @@ -345,6 +356,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: diff --git a/tests/test_worker.py b/tests/test_worker.py index 72abea85..3d1cc0b5 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -825,6 +825,7 @@ def mock_process_fn( ffmpeg_threads_override=None, cancel_check=None, worker_name=None, + fingerprint_store=None, ): call_order.append((item_key, gpu)) time.sleep(0.01) @@ -884,6 +885,7 @@ def mock_process_fn( ffmpeg_threads_override=None, cancel_check=None, worker_name=None, + fingerprint_store=None, ): call_order.append((item_key, gpu)) time.sleep(0.01) @@ -940,6 +942,7 @@ def mock_process_fn( ffmpeg_threads_override=None, cancel_check=None, worker_name=None, + fingerprint_store=None, ): time.sleep(0.01) if gpu is not None: @@ -1122,6 +1125,7 @@ def mock_process_fn( ffmpeg_threads_override=None, cancel_check=None, worker_name=None, + fingerprint_store=None, ): time.sleep(0.01) if gpu is not None: diff --git a/tests/test_worker_concurrency.py b/tests/test_worker_concurrency.py index c4dd739e..89cdcb40 100644 --- a/tests/test_worker_concurrency.py +++ b/tests/test_worker_concurrency.py @@ -36,6 +36,7 @@ def _fake_process_item( ffmpeg_threads_override=None, cancel_check=None, worker_name=None, + fingerprint_store=None, ): """Simulate FFmpeg processing with a tiny sleep.""" time.sleep(0.02) @@ -52,6 +53,7 @@ def _slow_process_item( ffmpeg_threads_override=None, cancel_check=None, worker_name=None, + fingerprint_store=None, ): """Simulate slow processing for shutdown/cancellation tests.""" time.sleep(0.5) @@ -68,6 +70,7 @@ def _very_slow_process_item( ffmpeg_threads_override=None, cancel_check=None, worker_name=None, + fingerprint_store=None, ): """Simulate long processing so headless polling emits worker updates.""" if progress_callback: @@ -92,6 +95,7 @@ def _failing_process_item( ffmpeg_threads_override=None, cancel_check=None, worker_name=None, + fingerprint_store=None, ): """Simulate a processing failure.""" raise RuntimeError("ffmpeg crashed") @@ -107,6 +111,7 @@ def _codec_error_process_item( ffmpeg_threads_override=None, cancel_check=None, worker_name=None, + fingerprint_store=None, ): """Simulate a GPU codec error that should fall back to CPU.""" from plex_generate_previews.media_processing import CodecNotSupportedError @@ -346,6 +351,7 @@ def gpu_then_cpu( ffmpeg_threads_override=None, cancel_check=None, worker_name=None, + fingerprint_store=None, ): gpu_call_count["n"] += 1 if gpu is not None: From 4771427d1d762a385a950f0956e5a351eadae37f Mon Sep 17 00:00:00 2001 From: "Adam J. Weigold" Date: Sun, 5 Apr 2026 16:16:35 -0500 Subject: [PATCH 3/3] fix: tighten credits heuristic and add detection debug viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Credits detection false positive fix: - Require 2+ black frames in a cluster (within 60s window) instead of accepting a single black frame, which triggered on scene transitions (e.g. Superman II flagged 12 min early from a scene-transition black frame) - Confidence levels: cluster+silence=0.9, single+silence=0.8, cluster-only=0.6, silence-only=0.4 (below 0.5 write threshold) Detection debug viewer (Tools → Detection Debug): - Enter a Plex item ID to run credits analysis without writing markers - Visual timeline showing black frames, silence regions, scan window, detected credits segment, and existing Plex markers - Tables with raw detection data (timestamps, durations) - Shows whether the result would pass the confidence threshold - Parameters used for the analysis - New API endpoint: POST /api/detection/analyze Co-Authored-By: Claude Opus 4.6 (1M context) --- plex_generate_previews/credits_detection.py | 85 ++++-- plex_generate_previews/web/routes/__init__.py | 1 + .../web/routes/api_detection.py | 218 ++++++++++++++ plex_generate_previews/web/routes/pages.py | 7 + .../web/templates/base.html | 7 +- .../web/templates/detection_debug.html | 269 ++++++++++++++++++ tests/test_credits_detection.py | 37 ++- 7 files changed, 601 insertions(+), 23 deletions(-) create mode 100644 plex_generate_previews/web/routes/api_detection.py create mode 100644 plex_generate_previews/web/templates/detection_debug.html diff --git a/plex_generate_previews/credits_detection.py b/plex_generate_previews/credits_detection.py index c2a484cc..468d7de4 100644 --- a/plex_generate_previews/credits_detection.py +++ b/plex_generate_previews/credits_detection.py @@ -229,6 +229,40 @@ def _regions_overlap_or_adjacent( 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, +) -> Optional[float]: + """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], @@ -238,12 +272,11 @@ def _combine_detections( ) -> Optional[CreditsSegment]: """Combine black frame and silence detections to identify credits. - Strategy: - 1. Find combined black+silence regions near the end of the video. - 2. The earliest qualifying region is the credits start. - 3. Credits extend to the end of the video. - 4. Require minimum duration to avoid false positives. - 5. Fall back to black-only or silence-only if no combined match. + 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. @@ -258,9 +291,27 @@ def _combine_detections( """ 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: Combined black + silence (highest confidence) - for bf in 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: @@ -271,24 +322,24 @@ def _combine_detections( return CreditsSegment( start_ms=int(credits_start * 1000), end_ms=end_ms, - confidence=0.9, + confidence=0.8, method="black+silence", ) - # Strategy 2: Black frames only (medium confidence) - for bf in sorted(black_frames, key=lambda f: f.start): - if bf.start < min_start_time: - continue - credits_duration = total_duration_sec - bf.start + # 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(bf.start * 1000), + start_ms=int(cluster_start * 1000), end_ms=end_ms, confidence=0.6, - method="black_only", + method="black_cluster", ) - # Strategy 3: Silence only (lower confidence) + # 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 diff --git a/plex_generate_previews/web/routes/__init__.py b/plex_generate_previews/web/routes/__init__.py index a65baf72..58c9c20d 100644 --- a/plex_generate_previews/web/routes/__init__.py +++ b/plex_generate_previews/web/routes/__init__.py @@ -14,6 +14,7 @@ # Import sub-modules to register their route decorators with the blueprints. # Order doesn't matter; each module imports `main` or `api` from this package. from . import api_bif # noqa: E402, F401 +from . import api_detection # noqa: E402, F401 from . import api_jobs # noqa: E402, F401 from . import api_plex # noqa: E402, F401 from . import api_schedules # noqa: E402, 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..3ef1ecb2 --- /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, + _run_blackdetect, + _run_silencedetect, + _combine_detections, + ) + 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/pages.py b/plex_generate_previews/web/routes/pages.py index 40715018..e9bde0e2 100644 --- a/plex_generate_previews/web/routes/pages.py +++ b/plex_generate_previews/web/routes/pages.py @@ -72,6 +72,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 3e3416fd..a65ba3df 100644 --- a/plex_generate_previews/web/templates/base.html +++ b/plex_generate_previews/web/templates/base.html @@ -56,7 +56,7 @@ diff --git a/plex_generate_previews/web/templates/detection_debug.html b/plex_generate_previews/web/templates/detection_debug.html new file mode 100644 index 00000000..20ea38de --- /dev/null +++ b/plex_generate_previews/web/templates/detection_debug.html @@ -0,0 +1,269 @@ +{% extends "base.html" %} + +{% block title %}Detection Debug - Plex Preview Generator{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+
+

Detection Debug

+
+ +
+
Analyze Media Item
+
+

+ 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. +

+
+ /library/metadata/ + + +
+
+
+ +
+
+
Item Info
+
+

Title:

+

File:

+

Duration:

+
+
+ +
+
Timeline
+
+
+ Black frames + Silence regions + Detected credits + Existing markers +
+
+
+ 0:00 + +
+
+
+ +
+
+
+
Black Frames (0)
+
+
+ + + +
StartEndDuration
+
+
+
+
+
+
+
Silence Regions (0)
+
+
+ + + +
StartEndDuration
+
+
+
+
+
+ +
+
Detection Result
+
+
+
+ +
+
Parameters Used
+
+

+            
+
+
+ +
+
+
+

Running detection analysis (this may take a minute for long videos)...

+
+
+
+{% endblock %} + +{% block scripts %} + + +{% endblock %} \ No newline at end of file diff --git a/tests/test_credits_detection.py b/tests/test_credits_detection.py index 2432e9cf..a4dfaa02 100644 --- a/tests/test_credits_detection.py +++ b/tests/test_credits_detection.py @@ -128,21 +128,48 @@ def test_combined_black_and_silence(self): ) assert result is not None assert result.method == "black+silence" - assert result.confidence == 0.9 + assert result.confidence == 0.8 # single black + silence assert result.start_ms == int(1799.5 * 1000) assert result.end_ms == 2000000 - def test_black_only_fallback(self): - """Black frames without silence → medium confidence.""" - black = [BlackFrame(start=1800.0, end=1801.0, duration=1.0)] + def test_cluster_black_and_silence(self): + """Multiple black frames + silence → highest confidence (0.9).""" + black = [ + BlackFrame(start=1800.0, end=1801.0, duration=1.0), + BlackFrame(start=1810.0, end=1811.0, duration=1.0), + ] + silence = [SilenceRegion(start=1799.5, end=1802.0, duration=2.5)] + + result = _combine_detections( + black, silence, total_duration_sec=2000.0, min_credits_duration_sec=15.0 + ) + assert result is not None + assert result.method == "black+silence" + assert result.confidence == 0.9 + + def test_black_cluster_only_fallback(self): + """Multiple black frames without silence → medium confidence.""" + black = [ + BlackFrame(start=1800.0, end=1801.0, duration=1.0), + BlackFrame(start=1810.0, end=1811.0, duration=1.0), + ] result = _combine_detections( black, [], total_duration_sec=2000.0, min_credits_duration_sec=15.0 ) assert result is not None - assert result.method == "black_only" + assert result.method == "black_cluster" assert result.confidence == 0.6 + def test_single_black_only_rejected(self): + """Single black frame without silence → no match (avoids scene transition false positive).""" + black = [BlackFrame(start=1800.0, end=1801.0, duration=1.0)] + + result = _combine_detections( + black, [], total_duration_sec=2000.0, min_credits_duration_sec=15.0 + ) + assert result is None # single black frame rejected — need cluster or silence + def test_silence_only_fallback(self): """Silence without black frames → lower confidence.""" silence = [SilenceRegion(start=1800.0, end=1805.0, duration=5.0)]