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