Skip to content
kLabs
Go back

Solving Shuffled Episode Archives with Python, FFmpeg, and Tesseract OCR

Edit page

When managing TV show backups or digital media archives for a local server like Plex or Jellyfin, you often encounter an annoying hurdle: shuffled tracks.

When filenames are generic and metadata is missing, conventional scrapers fail. Video matching tools also struggle because embedded graphic DVD subtitles (dvdsub) are raw bitmapped images rather than plain text.

To solve this, I vibe-coded a Python CLI tool that extracts embedded subtitle images, converts them to text via Optical Character Recognition (OCR), and matches dialogue keywords against online reference .srt files to automatically rename every file.

Table of contents

Open Table of contents

Pipeline

  1. Stream Inspection (ffprobe): Scans the MKV file to locate the absolute stream index for the English subtitle track.
  2. Filtergraph Canvas Rendering (ffmpeg): Renders transparent graphic subtitles onto a synthetic black background at 0.5 frames per second for the first 5 minutes.
  3. Native OCR (tesseract): Scans the generated PNG frames and converts visual text into plain-text strings.
  4. Fuzzy Keyword Matching: Compares extracted dialogue words against pre-indexed reference subtitle files (.srt).
  5. Defensive Renaming: Applies structured filenames (Series - S01E01.mkv) while flagging duplicate tracks with a _DUP suffix to prevent accidental overwrites.
[MKV File] ➔ [ffprobe stream lookup] ➔ [FFmpeg overlay on black canvas] ➔ [PNG Frames] ➔ [Tesseract OCR] ➔ [Keyword Match] ➔ [Rename]

Python Script

Save the code below as ocr_rename.py:

import os
import subprocess
import json
import re
import shutil
import argparse
from pathlib import Path

# --- CONFIGURATION ---
TEMP_DIR = "./sub_temp"    # Temporary directory for OCR frame rendering
# ---------------------

def extract_pure_text(srt_content):
    """Removes timestamps and formatting tags for clean text comparisons."""
    text = re.sub(r'\d+\r?\n\d\d:\d\d:\d\d[,\.]\d\d\d --> \d\d:\d\d:\d\d[,\.]\d\d\d\r?\n', '', srt_content)
    text = re.sub(r'<[^>]*>', '', text)
    words = re.findall(r'\b\w+\b', text.lower())
    return " ".join(words)

def get_english_subtitle_stream_index(mkv_path):
    """Retrieves the exact absolute stream ID of the English subtitle track via ffprobe."""
    cmd = [
        "ffprobe", "-v", "error", 
        "-show_entries", "stream=index,codec_type:stream_tags=language", 
        "-of", "json", str(mkv_path)
    ]
    try:
        result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, check=True)
        data = json.loads(result.stdout)
        for stream in data.get("streams", []):
            if stream.get("codec_type") == "subtitle":
                lang = stream.get("tags", {}).get("language", "").lower()
                if lang in ["eng", "en"]:
                    return stream.get("index")
    except Exception:
        pass
    return None

def convert_dvdsub_with_tesseract(mkv_path):
    """Renders graphic subtitles onto a black video canvas and processes them with Tesseract OCR."""
    temp_path = Path(TEMP_DIR)
    if temp_path.exists():
        shutil.rmtree(temp_path)
    temp_path.mkdir(parents=True, exist_ok=True)

    stream_index = get_english_subtitle_stream_index(mkv_path)
    if stream_index is None:
        return None

    # Render subtitles on a black canvas (0.5 FPS for 5 minutes)
    ffmpeg_cmd = [
        "ffmpeg", "-y",
        "-f", "lavfi", "-i", "color=c=black:s=720x576:r=0.5",
        "-i", str(mkv_path),
        "-filter_complex", f"[0:v][1:{stream_index}]overlay=shortest=1",
        "-to", "00:05:00",
        "-an",
        f"{TEMP_DIR}/sub-%03d.png"
    ]
    
    result = subprocess.run(ffmpeg_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)

    png_files = sorted(list(temp_path.glob("*.png")))
    
    if not png_files:
        print(f"\n[FFmpeg Error Log for {mkv_path.name}]:")
        print(result.stderr)
        return None

    extracted_lines = []
    for png in png_files:
        tesseract_cmd = ["tesseract", str(png), "stdout", "-l", "eng", "--psm", "3"]
        res = subprocess.run(tesseract_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
        if res.stdout.strip():
            extracted_lines.append(res.stdout.strip())

    shutil.rmtree(temp_path)
    
    full_text = " ".join(extracted_lines)
    return extract_pure_text(full_text)

def parse_ref_subtitle_filename(filename):
    """Extracts season and episode numbers from formats like '1x13' or 'S01E13'."""
    match = re.search(r'(\d+)x(\d+)', filename)
    if match:
        return int(match.group(1)), int(match.group(2))
    
    match_sxxexx = re.search(r'[sS](\d+)[eE](\d+)', filename)
    if match_sxxexx:
        return int(match_sxxexx.group(1)), int(match_sxxexx.group(2))
        
    return None, None

def main():
    parser = argparse.ArgumentParser(description="Automated MKV episode renamer based on subtitle OCR matching.")
    parser.add_argument("-m", "--mkv-dir", default=".",
                        help="Path to the directory containing MKV files")
    parser.add_argument("-r", "--ref-dir", default="./ref_subs",
                        help="Path to the directory containing reference SRT files")
    parser.add_argument("-s", "--series", default="Series",
                        help="Series title used for output filenames (e.g. 'Big Time Rush (2009)' or 'The Office')")
    args = parser.parse_args()

    mkv_base_path = Path(args.mkv_dir).resolve()
    ref_path = Path(args.ref_dir).resolve()
    series_name = args.series.strip()

    if not ref_path.exists():
        print(f"[Error] Reference directory '{ref_path}' does not exist!")
        return

    print(f"-> Target MKV Directory: {mkv_base_path}")
    print(f"-> Reference SRT Directory: {ref_path}")
    print(f"-> Series Title: '{series_name}'")

    print("-> Indexing reference subtitles...")
    reference_tracks = []
    for ref_file in ref_path.glob("*.srt"):
        season, episode = parse_ref_subtitle_filename(ref_file.name)
        if season is None or episode is None:
            continue
            
        with open(ref_file, "r", encoding="utf-8", errors="ignore") as f:
            pure_text = extract_pure_text(f.read())
            reference_tracks.append({
                "season": season,
                "episode": episode,
                "text": pure_text[:5000]
            })

    print(f"   Indexed {len(reference_tracks)} reference episodes.")
    print("\n-> Starting OCR analysis...")

    mkv_files = [f for f in mkv_base_path.glob("*.mkv") if f.stat().st_size < 4 * 1024 * 1024 * 1024]
    for sub_dir in mkv_base_path.iterdir():
        if sub_dir.is_dir() and sub_dir.name != ref_path.name and not sub_dir.name.startswith("."):
            mkv_files.extend([f for f in sub_dir.glob("*.mkv") if f.stat().st_size < 4 * 1024 * 1024 * 1024])

    if not mkv_files:
        print("   No matching MKV files found.")
        return

    print(f"   Found {len(mkv_files)} video files for processing.\n")

    for mkv_file in sorted(mkv_files):
        print(f"   Analyzing {mkv_file.name} (Filtergraph OCR)... ", end="", flush=True)
        
        local_text = convert_dvdsub_with_tesseract(mkv_file)
        if not local_text:
            print("-> [Error] No English subtitles extracted.")
            continue

        sample_words = local_text.split()
        if not sample_words:
            print("-> [Error] No text recognized within the sampling window.")
            continue

        best_match = None
        max_hits = 0

        for ref in reference_tracks:
            hits = sum(1 for word in sample_words if word in ref["text"])
            if hits > max_hits:
                max_hits = hits
                best_match = ref

        if best_match and max_hits > 8:
            s = best_match["season"]
            e = best_match["episode"]
            
            new_filename = f"{series_name} - S{s:02d}E{e:02d}.mkv"
            target_path = mkv_file.parent / new_filename
            
            if target_path.exists():
                print(f" -> [WARNING] Target file exists! Preserving duplicate...", end="")
                new_filename = f"{series_name} - S{s:02d}E{e:02d}_DUP.mkv"
                target_path = mkv_file.parent / new_filename

            mkv_file.rename(target_path)
            print(f"-> [Match!] S{s:02d}E{e:02d} ({max_hits} Keywords) -> Renamed.")
        else:
            print("-> [Error] No confident match found.")

    print("\nProcessing complete!")

if __name__ == "__main__":
    main()

Prerequisites & Dependencies

Before running the script, ensure your system has ffmpeg, tesseract, and python3 installed.

Linux (Ubuntu / Debian)

sudo apt update
sudo apt install ffmpeg tesseract-ocr tesseract-ocr-eng python3

Linux (Fedora / RHEL)

sudo dnf install ffmpeg tesseract tesseract-langpack-eng python3

How to Use

  1. Download or organize clean reference subtitles (.srt format) into a folder named ref_subs. The reference filenames can follow standard formats like Show - 1x01.srt or S01E01.srt.
  2. Run the script with custom paths and a custom series title:
python3 ocr_rename.py \
  -m "/path/to/media_files" \
  -r "/path/to/ref_subs" \
  -s "Your Favorite Show (1986)"

Disclaimer

Disclaimer: This script renames files directly on your storage system. Always test automation scripts on file copies or isolated testing directories before applying them to primary media archives.


Edit page
Share this post:

Next Post
Automated Power Consumption Investigation using HomeAssistant and Gemini