Skip to main content

chives: add a function/CLI command to convert SRT to VTT; plus site tests

ID
c066caf
date
2026-07-13 18:38:30+00:00
author
Alex Chan <alex@alexwlchan.net>
parent
e644df5
message
chives: add a function/CLI command to convert SRT to VTT; plus site tests
changed files
10 files, 217 additions, 1 deletion

Changed files

CHANGELOG.md (5261) → CHANGELOG.md (5618)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index d601cdd..be20b6a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
 # CHANGELOG
 
+## v44 - 2026-07-13
+
+Improve support for VTT subtitles (which work with the HTML5-native `<video>` element) rather than SRT:
+  
+*   Add a function `media.convert_srt_to_vtt` to convert SRT subtitles to VTT.
+*   Add an `srt-to-vtt` CLI command to convert SRT subtitles to VTT.
+*   Add a test in `StaticSiteTestSuite` that checks there are no SRT subtitles.
+
 ## v43 - 2026-06-27
 
 Use file signatures to improve image format detection in `fetch.download_image()`.

pyproject.toml (1511) → pyproject.toml (1579)

diff --git a/pyproject.toml b/pyproject.toml
index c29f4f7..be6ff22 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -30,6 +30,9 @@ media = ["Pillow"]
 static_site_tests = ["pytest"]
 urls = ["certifi"]
 
+[project.scripts]
+srt-to-vtt = "chives.cli:convert_srt_to_vtt_cli"
+
 [project.urls]
 "Homepage" = "https://alexwlchan.net/projects/chives/"
 "Changelog" = "https://alexwlchan.net/projects/chives/releases/"

scripts/run_chives_tests.sh (666) → scripts/run_chives_tests.sh (708)

diff --git a/scripts/run_chives_tests.sh b/scripts/run_chives_tests.sh
index 3bf66df..1130ad5 100755
--- a/scripts/run_chives_tests.sh
+++ b/scripts/run_chives_tests.sh
@@ -30,5 +30,6 @@ fi
 
 run_command 'ruff check'
 run_command 'ty check src tests'
+run_command 'uv pip install --quiet -e .'
 run_command "python3 -m coverage run -m pytest -q tests"
 report_coverage

src/chives/__init__.py (391) → src/chives/__init__.py (391)

diff --git a/src/chives/__init__.py b/src/chives/__init__.py
index 49e0e90..3ad17cb 100644
--- a/src/chives/__init__.py
+++ b/src/chives/__init__.py
@@ -11,4 +11,4 @@ I share across multiple sites.
 
 """
 
-__version__ = "43"
+__version__ = "44"

src/chives/cli.py (0) → src/chives/cli.py (763)

diff --git a/src/chives/cli.py b/src/chives/cli.py
new file mode 100644
index 0000000..c9c680f
--- /dev/null
+++ b/src/chives/cli.py
@@ -0,0 +1,27 @@
+"""
+Command-line scripts for running common Chives actions.
+"""
+
+import os
+from pathlib import Path
+import sys
+
+from chives.media import convert_srt_to_vtt
+
+
+def convert_srt_to_vtt_cli() -> None:  # pragma: no cover
+    """
+    Convert one or more SRT subtitle files to VTT.
+    """
+    # Coverage note: this function is fully tested in test_cli.py, but
+    # it's invoked via subprocess and it's tricky to collect coverage.
+    # For now, I'm trusting this function is simple enough I don't need
+    # to automatically collect coverage.
+    paths = sys.argv[1:]
+
+    if len(paths) == 0:
+        sys.exit(f"Usage: {os.path.basename(sys.argv[0])} <SRT_PATH>...")
+
+    for p in paths:
+        vtt_path = convert_srt_to_vtt(srt_path=Path(p))
+        print(vtt_path)

src/chives/media.py (10579) → src/chives/media.py (11785)

diff --git a/src/chives/media.py b/src/chives/media.py
index b5cf39d..1fcc4a3 100644
--- a/src/chives/media.py
+++ b/src/chives/media.py
@@ -25,6 +25,7 @@ if TYPE_CHECKING:
 
 
 __all__ = [
+    "convert_srt_to_vtt",
     "create_image_entity",
     "create_video_entity",
     "get_media_paths",
@@ -410,3 +411,42 @@ def _get_video_data(video_path: str | Path) -> tuple[int, int, str]:
     duration = video_stream["duration"]
 
     return width, height, duration
+
+
+def convert_srt_to_vtt(srt_path: Path) -> Path:
+    """
+    Convert an SRT subtitles file to a VTT file.
+
+    SRT subtitles are commonly used for video platform uploads (like Vimeo
+    or YouTube), but I want VTT files for use in the HTML5 <video> tag.
+
+    This deletes the SRT file and returns the path to new VTT file.
+    """
+    if srt_path.suffix != ".srt":
+        raise ValueError(
+            f"convert_srt_to_vtt can only take .srt files, got {srt_path!r}"
+        )
+
+    with open(srt_path) as srt_file:
+        srt_lines = srt_file.readlines()
+
+    vtt_path = srt_path.with_suffix(".vtt")
+    assert vtt_path != srt_path, vtt_path
+
+    with open(vtt_path, "w") as vtt_file:
+        # Write the required WEBVTT header and blank line
+        vtt_file.write("WEBVTT\n\n")
+
+        for line in srt_lines:
+            # Remove caption numbers; they're not required in VTT files
+            if line.strip().isdigit():
+                continue
+
+            # VTT timestamps uses commas rather than periods in timestamps
+            if "-->" in line:
+                line = line.replace(",", ".")
+
+            vtt_file.write(line)
+
+    srt_path.unlink()
+    return vtt_path

src/chives/static_site_tests.py (7719) → src/chives/static_site_tests.py (8165)

diff --git a/src/chives/static_site_tests.py b/src/chives/static_site_tests.py
index 75f8196..ba1d107 100644
--- a/src/chives/static_site_tests.py
+++ b/src/chives/static_site_tests.py
@@ -199,6 +199,19 @@ class StaticSiteTestSuite[M](ABC):
 
         assert av1_videos == set(), f"Found videos encoded with AV1: {av1_videos}"
 
+    def test_no_subtitles_are_srt(self, site_root: Path) -> None:
+        """
+        No subtitles are encoded as SRT (which don't play in the HTML5-native
+        <video> element).
+
+        All subtitles should be encoded as VTT.
+        """
+        srt_subtitles = set(glob.glob("**/*.srt", root_dir=site_root, recursive=True))
+
+        assert srt_subtitles == set(), (
+            f"Found subtitles encoded as SRT: {srt_subtitles}"
+        )
+
     date_formats = [
         "%Y-%m-%dT%H:%M:%SZ",
         "%Y-%m-%d",

tests/test_cli.py (0) → tests/test_cli.py (1499)

diff --git a/tests/test_cli.py b/tests/test_cli.py
new file mode 100644
index 0000000..06bf0d0
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,52 @@
+"""
+Tests for the CLI scripts.
+"""
+
+from pathlib import Path
+import subprocess
+from subprocess import PIPE
+
+from chives import cli  # noqa: F401
+
+
+class TestConvertSrtToVtt:
+    """
+    Tests for the `srt-to-vtt` command.
+    """
+
+    def test_no_args(self) -> None:
+        """
+        If you don't supply any arguments, you get an error.
+        """
+        proc = subprocess.Popen(["srt-to-vtt"], stdout=PIPE, stderr=PIPE, text=True)
+        stdout, stderr = proc.communicate(timeout=1)
+
+        assert proc.returncode == 1
+        assert stdout == ""
+        assert stderr == "Usage: srt-to-vtt <SRT_PATH>...\n"
+
+    def test_multiple_args(self, tmp_path: Path) -> None:
+        """
+        If you supply multiple SRT paths, they all get converted to VTT.
+        """
+        srt_path1 = tmp_path / "example1.en.srt"
+        srt_path1.write_text(
+            "1\n00:00:01,001 --> 00:00:10,010\nSomebody said the first thing\n"
+        )
+
+        srt_path2 = tmp_path / "example2.en.srt"
+        srt_path2.write_text(
+            "1\n00:02:00,002 --> 00:20:00,020\nSomebody else said the second thing\n"
+        )
+
+        proc = subprocess.Popen(
+            ["srt-to-vtt", str(srt_path1), str(srt_path2)],
+            stdout=PIPE,
+            stderr=PIPE,
+            text=True,
+        )
+        stdout, stderr = proc.communicate(timeout=1)
+
+        assert proc.returncode == 0
+        assert stdout == (f"{tmp_path}/example1.en.vtt\n{tmp_path}/example2.en.vtt\n")
+        assert stderr == ""

tests/test_media.py (14721) → tests/test_media.py (16303)

diff --git a/tests/test_media.py b/tests/test_media.py
index 081c1b8..1a73457 100644
--- a/tests/test_media.py
+++ b/tests/test_media.py
@@ -7,6 +7,7 @@ from PIL import Image
 import pytest
 
 from chives.media import (
+    convert_srt_to_vtt,
     create_image_entity,
     create_video_entity,
     get_media_paths,
@@ -425,3 +426,53 @@ class TestGetMediaPaths:
         """
         with pytest.raises(TypeError):
             get_media_paths(bad_entity)
+
+
+class TestConvertSrtToVtt:
+    """
+    Tests for `convert_srt_to_vtt`.
+    """
+
+    def test_converting_srt_file(self, tmp_path: Path) -> None:
+        """
+        Check an SRT file is converted to VTT correctly.
+        """
+        srt_path = tmp_path / "example.en.srt"
+        srt_path.write_text(
+            "1\n"
+            "00:00:01,001 --> 00:00:10,010\n"
+            "Somebody said the first thing\n"
+            "\n"
+            "2\n"
+            "00:02:00,002 --> 00:20:00,020\n"
+            "Somebody else said the second thing\n"
+            "\n"
+            "3\n"
+            "03:00:00,003 --> 30:00:00,300\n"
+            "Yet another person said the third thing!"
+        )
+
+        vtt_path = convert_srt_to_vtt(srt_path)
+        assert vtt_path == (tmp_path / "example.en.vtt")
+        assert vtt_path.exists()
+        assert not srt_path.exists()
+
+        assert vtt_path.read_text() == (
+            "WEBVTT\n"
+            "\n"
+            "00:00:01.001 --> 00:00:10.010\n"
+            "Somebody said the first thing\n"
+            "\n"
+            "00:02:00.002 --> 00:20:00.020\n"
+            "Somebody else said the second thing\n"
+            "\n"
+            "03:00:00.003 --> 30:00:00.300\n"
+            "Yet another person said the third thing!"
+        )
+
+    def test_converting_non_srt_file_is_error(self) -> None:
+        """
+        Converting a non-SRT file returns an error.
+        """
+        with pytest.raises(ValueError, match="can only take .srt files"):
+            convert_srt_to_vtt(srt_path=Path("example.txt"))

tests/test_static_site_tests.py (8827) → tests/test_static_site_tests.py (9666)

diff --git a/tests/test_static_site_tests.py b/tests/test_static_site_tests.py
index b3f01f7..f6f5b67 100644
--- a/tests/test_static_site_tests.py
+++ b/tests/test_static_site_tests.py
@@ -199,6 +199,27 @@ def test_checks_for_av1_videos(pytester: Pytester, site_root: Path) -> None:
     pytester.runpytest("-k", keyword).assert_outcomes(failed=1)
 
 
+def test_checks_for_srt_subtitles(pytester: Pytester, site_root: Path) -> None:
+    """
+    The tests check for SRT-encoded subtitles, and fail if they're detected.
+    """
+    create_pyfile(pytester, site_root)
+
+    keyword = "test_no_subtitles_are_srt"
+
+    # This should pass trivially when the site is empty.
+    pytester.runpytest("-k", keyword).assert_outcomes(passed=1)
+
+    # Add a VTT-encoded subtitle, and check the test still passes.
+    (site_root / "my-great-show").mkdir()
+    (site_root / "my-great-show/e01 - Episode the First.en.vtt").write_text("")
+    pytester.runpytest("-k", keyword).assert_outcomes(passed=1)
+
+    # Add an SRT-encoded subtitle, and check the test fails
+    (site_root / "my-great-show/e02 - Episode the Second.en.srt").write_text("")
+    pytester.runpytest("-k", keyword).assert_outcomes(failed=1)
+
+
 class TestAllTimestampsAreConsistent:
     """
     Tests for the `test_all_timestamps_are_consistent` method.