tvsub-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Youngji Kim
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: tvsub-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP companion for the tvsub subtitle overlay on Apple TV.app for macOS
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://youngji.kim/tvsub
7
+ Project-URL: Repository, https://github.com/launzi/tvsub-mcp
8
+ Project-URL: Issues, https://github.com/launzi/tvsub-mcp/issues
9
+ Keywords: mcp,subtitles,macos,apple-tv
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: MacOS X
12
+ Classifier: Operating System :: MacOS
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Requires-Python: >=3.12
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: mcp<2,>=1.2
19
+ Requires-Dist: anthropic<1,>=0.117
20
+ Dynamic: license-file
21
+
22
+ # tvsub MCP
23
+
24
+ <!-- mcp-name: io.github.launzi/tvsub-mcp -->
25
+
26
+ `tvsub-mcp` is the MCP companion for [tvsub](https://youngji.kim/tvsub), an
27
+ experimental subtitle overlay for Apple TV.app on macOS. It lets an MCP client
28
+ inspect the current playback item, choose or translate a subtitle file, adjust
29
+ its appearance, start or stop the overlay, and calibrate subtitle timing.
30
+
31
+ The server does not download subtitles, bypass DRM, modify video, or launch
32
+ TV.app. You provide subtitle files that you have the right to use and start
33
+ playback yourself.
34
+
35
+ ## Requirements
36
+
37
+ - macOS
38
+ - [tvsub](https://youngji.kim/tvsub), installed and built
39
+ - Python 3.12 or later
40
+ - [`uv`](https://docs.astral.sh/uv/getting-started/installation/) for the
41
+ recommended `uvx` installation
42
+ - An Anthropic API key only if you use subtitle translation
43
+
44
+ ## Install and register with Claude Code
45
+
46
+ Replace `/absolute/path/to/tvsub` with the directory containing tvsub's
47
+ `build/`, `config/`, `src/`, and `subtitles/` directories.
48
+
49
+ ```bash
50
+ brew install uv
51
+
52
+ claude mcp add --transport stdio --scope user tvsub -- \
53
+ uvx tvsub-mcp==0.1.0 \
54
+ --tvsub-root /absolute/path/to/tvsub
55
+
56
+ claude mcp get tvsub
57
+ claude mcp list
58
+ ```
59
+
60
+ To enable translation, export your key and include it when registering the
61
+ server. The key is not needed for any other tool.
62
+
63
+ ```bash
64
+ export ANTHROPIC_API_KEY="your-key"
65
+
66
+ claude mcp add --transport stdio --scope user \
67
+ --env ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
68
+ tvsub -- uvx tvsub-mcp==0.1.0 \
69
+ --tvsub-root /absolute/path/to/tvsub
70
+ ```
71
+
72
+ Other stdio MCP clients can launch the same command:
73
+
74
+ ```bash
75
+ uvx tvsub-mcp==0.1.0 --tvsub-root /absolute/path/to/tvsub
76
+ ```
77
+
78
+ ## Tools
79
+
80
+ | Tool | Purpose |
81
+ | --- | --- |
82
+ | `now_playing` | Read the current Apple TV.app title, content ID, position, and playback state. |
83
+ | `list_subtitles` | List and parse SRT, SMI, SAMI, and VTT files in tvsub's subtitle library. |
84
+ | `load_subtitle` | Select a subtitle file for the current content while preserving sync anchors by default. |
85
+ | `translate_subtitle` | Estimate or perform an LLM translation with cue and timecode validation. |
86
+ | `list_fonts` | List installed macOS fonts and check sample glyph coverage. |
87
+ | `set_style` | Change font, size, colors, outline, background, and screen position. |
88
+ | `start_overlay` | Start tvsub with the selected subtitle and style. |
89
+ | `stop_overlay` | Stop only the overlay process started by this server. |
90
+ | `calibrate_sync` | Store one or more dialogue anchors and calculate timing offset and drift. |
91
+ | `status` | Summarize playback, overlay, subtitle, style, and calibration state. |
92
+
93
+ Before translating, call `translate_subtitle` with `dry_run=true` to review the
94
+ cue count, batch count, and estimated cost.
95
+
96
+ ## Important notices
97
+
98
+ - **Experimental software:** expect rough edges and breaking changes. Keep a
99
+ backup of your tvsub configuration and subtitle files.
100
+ - **Data sent to Anthropic:** translation sends the selected subtitle text and
101
+ surrounding subtitle context to the Anthropic API. Loading, styling, sync,
102
+ and overlay controls do not send subtitle text to Anthropic.
103
+ - **User-paid API usage:** translation uses your Anthropic API key. All API
104
+ charges are your responsibility; estimates can differ from the final bill.
105
+ - **Private API risk:** tvsub reads Apple playback state through undocumented
106
+ macOS MediaRemote interfaces. Apple does not support this integration and a
107
+ macOS update may change or disable it.
108
+ - **Content rights:** you are responsible for having the right to process and
109
+ translate subtitle files. Do not redistribute protected content without
110
+ permission.
111
+ - This project is independent from and not affiliated with Apple or Anthropic.
112
+
113
+ ## Development
114
+
115
+ ```bash
116
+ python3.12 -m venv .venv
117
+ .venv/bin/python -m pip install -e .
118
+ .venv/bin/python -m unittest discover -s tests -p 'test_*.py' -v
119
+ TVSUB_TEST_PYTHON="$PWD/.venv/bin/python" .venv/bin/python tests/stdio_smoke.py
120
+ bash scripts/hygiene-check.sh
121
+ ```
122
+
123
+ Linux can run the unit tests and mock stdio smoke test. Apple TV.app,
124
+ MediaRemote, CoreText, and the real overlay require macOS.
125
+
126
+ ## License
127
+
128
+ MIT. See [LICENSE](LICENSE).
129
+
@@ -0,0 +1,108 @@
1
+ # tvsub MCP
2
+
3
+ <!-- mcp-name: io.github.launzi/tvsub-mcp -->
4
+
5
+ `tvsub-mcp` is the MCP companion for [tvsub](https://youngji.kim/tvsub), an
6
+ experimental subtitle overlay for Apple TV.app on macOS. It lets an MCP client
7
+ inspect the current playback item, choose or translate a subtitle file, adjust
8
+ its appearance, start or stop the overlay, and calibrate subtitle timing.
9
+
10
+ The server does not download subtitles, bypass DRM, modify video, or launch
11
+ TV.app. You provide subtitle files that you have the right to use and start
12
+ playback yourself.
13
+
14
+ ## Requirements
15
+
16
+ - macOS
17
+ - [tvsub](https://youngji.kim/tvsub), installed and built
18
+ - Python 3.12 or later
19
+ - [`uv`](https://docs.astral.sh/uv/getting-started/installation/) for the
20
+ recommended `uvx` installation
21
+ - An Anthropic API key only if you use subtitle translation
22
+
23
+ ## Install and register with Claude Code
24
+
25
+ Replace `/absolute/path/to/tvsub` with the directory containing tvsub's
26
+ `build/`, `config/`, `src/`, and `subtitles/` directories.
27
+
28
+ ```bash
29
+ brew install uv
30
+
31
+ claude mcp add --transport stdio --scope user tvsub -- \
32
+ uvx tvsub-mcp==0.1.0 \
33
+ --tvsub-root /absolute/path/to/tvsub
34
+
35
+ claude mcp get tvsub
36
+ claude mcp list
37
+ ```
38
+
39
+ To enable translation, export your key and include it when registering the
40
+ server. The key is not needed for any other tool.
41
+
42
+ ```bash
43
+ export ANTHROPIC_API_KEY="your-key"
44
+
45
+ claude mcp add --transport stdio --scope user \
46
+ --env ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
47
+ tvsub -- uvx tvsub-mcp==0.1.0 \
48
+ --tvsub-root /absolute/path/to/tvsub
49
+ ```
50
+
51
+ Other stdio MCP clients can launch the same command:
52
+
53
+ ```bash
54
+ uvx tvsub-mcp==0.1.0 --tvsub-root /absolute/path/to/tvsub
55
+ ```
56
+
57
+ ## Tools
58
+
59
+ | Tool | Purpose |
60
+ | --- | --- |
61
+ | `now_playing` | Read the current Apple TV.app title, content ID, position, and playback state. |
62
+ | `list_subtitles` | List and parse SRT, SMI, SAMI, and VTT files in tvsub's subtitle library. |
63
+ | `load_subtitle` | Select a subtitle file for the current content while preserving sync anchors by default. |
64
+ | `translate_subtitle` | Estimate or perform an LLM translation with cue and timecode validation. |
65
+ | `list_fonts` | List installed macOS fonts and check sample glyph coverage. |
66
+ | `set_style` | Change font, size, colors, outline, background, and screen position. |
67
+ | `start_overlay` | Start tvsub with the selected subtitle and style. |
68
+ | `stop_overlay` | Stop only the overlay process started by this server. |
69
+ | `calibrate_sync` | Store one or more dialogue anchors and calculate timing offset and drift. |
70
+ | `status` | Summarize playback, overlay, subtitle, style, and calibration state. |
71
+
72
+ Before translating, call `translate_subtitle` with `dry_run=true` to review the
73
+ cue count, batch count, and estimated cost.
74
+
75
+ ## Important notices
76
+
77
+ - **Experimental software:** expect rough edges and breaking changes. Keep a
78
+ backup of your tvsub configuration and subtitle files.
79
+ - **Data sent to Anthropic:** translation sends the selected subtitle text and
80
+ surrounding subtitle context to the Anthropic API. Loading, styling, sync,
81
+ and overlay controls do not send subtitle text to Anthropic.
82
+ - **User-paid API usage:** translation uses your Anthropic API key. All API
83
+ charges are your responsibility; estimates can differ from the final bill.
84
+ - **Private API risk:** tvsub reads Apple playback state through undocumented
85
+ macOS MediaRemote interfaces. Apple does not support this integration and a
86
+ macOS update may change or disable it.
87
+ - **Content rights:** you are responsible for having the right to process and
88
+ translate subtitle files. Do not redistribute protected content without
89
+ permission.
90
+ - This project is independent from and not affiliated with Apple or Anthropic.
91
+
92
+ ## Development
93
+
94
+ ```bash
95
+ python3.12 -m venv .venv
96
+ .venv/bin/python -m pip install -e .
97
+ .venv/bin/python -m unittest discover -s tests -p 'test_*.py' -v
98
+ TVSUB_TEST_PYTHON="$PWD/.venv/bin/python" .venv/bin/python tests/stdio_smoke.py
99
+ bash scripts/hygiene-check.sh
100
+ ```
101
+
102
+ Linux can run the unit tests and mock stdio smoke test. Apple TV.app,
103
+ MediaRemote, CoreText, and the real overlay require macOS.
104
+
105
+ ## License
106
+
107
+ MIT. See [LICENSE](LICENSE).
108
+
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tvsub-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP companion for the tvsub subtitle overlay on Apple TV.app for macOS"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ keywords = ["mcp", "subtitles", "macos", "apple-tv"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: MacOS X",
17
+ "Operating System :: MacOS",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.12",
20
+ ]
21
+ dependencies = [
22
+ "mcp>=1.2,<2",
23
+ "anthropic>=0.117,<1",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://youngji.kim/tvsub"
28
+ Repository = "https://github.com/launzi/tvsub-mcp"
29
+ Issues = "https://github.com/launzi/tvsub-mcp/issues"
30
+
31
+ [project.scripts]
32
+ tvsub-mcp = "tvsub_mcp.server:main"
33
+
34
+ [tool.setuptools.package-data]
35
+ tvsub_mcp = ["helpers/*.swift"]
36
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import unittest
4
+
5
+ from tvsub_mcp.calibration import calculate
6
+
7
+
8
+ class CalibrationTests(unittest.TestCase):
9
+ def test_no_anchor_is_identity(self) -> None:
10
+ result = calculate([])
11
+ self.assertEqual(result.scale, 1.0)
12
+ self.assertEqual(result.offset, 0.0)
13
+
14
+ def test_one_anchor_is_offset_only(self) -> None:
15
+ result = calculate([{"subTime": 100, "actualTime": 107.27}])
16
+ self.assertEqual(result.scale, 1.0)
17
+ self.assertAlmostEqual(result.offset, 7.27)
18
+
19
+ def test_two_anchor_pal_scale(self) -> None:
20
+ anchors = [
21
+ {"subTime": 100, "actualTime": 100 * 1.0427 + 3},
22
+ {"subTime": 7000, "actualTime": 7000 * 1.0427 + 3},
23
+ ]
24
+ result = calculate(anchors)
25
+ self.assertAlmostEqual(result.scale, 1.0427, places=6)
26
+ self.assertAlmostEqual(result.offset, 3.0, places=6)
27
+ self.assertIsNone(result.warning)
28
+
29
+ def test_close_anchors_warn(self) -> None:
30
+ result = calculate([
31
+ {"subTime": 10, "actualTime": 12},
32
+ {"subTime": 20, "actualTime": 22},
33
+ ])
34
+ self.assertIn("불안정", result.warning or "")
35
+
36
+
37
+ if __name__ == "__main__":
38
+ unittest.main()
39
+
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import tempfile
5
+ import unittest
6
+ from pathlib import Path
7
+ from unittest.mock import patch
8
+
9
+ from tvsub_mcp.server import _load_env
10
+
11
+
12
+ class ServerEnvironmentTests(unittest.TestCase):
13
+ def test_load_env_reads_only_explicit_file_and_allowed_key(self) -> None:
14
+ with tempfile.TemporaryDirectory() as temp:
15
+ root = Path(temp)
16
+ explicit = root / "project" / ".env"
17
+ explicit.parent.mkdir()
18
+ explicit.write_text(
19
+ "ANTHROPIC_API_KEY=fixture-key\nUNRELATED_SECRET=must-not-load\n",
20
+ encoding="utf-8",
21
+ )
22
+ (root / ".env").write_text("ANTHROPIC_API_KEY=parent-key\n", encoding="utf-8")
23
+ with patch.dict(os.environ, {}, clear=True):
24
+ _load_env(explicit)
25
+ self.assertEqual(os.environ.get("ANTHROPIC_API_KEY"), "fixture-key")
26
+ self.assertNotIn("UNRELATED_SECRET", os.environ)
27
+
28
+ def test_load_env_does_not_search_parent_directories(self) -> None:
29
+ with tempfile.TemporaryDirectory() as temp:
30
+ root = Path(temp)
31
+ (root / ".env").write_text("ANTHROPIC_API_KEY=parent-key\n", encoding="utf-8")
32
+ missing_explicit = root / "project" / ".env"
33
+ with patch.dict(os.environ, {}, clear=True):
34
+ _load_env(missing_explicit)
35
+ self.assertNotIn("ANTHROPIC_API_KEY", os.environ)
36
+
37
+
38
+ if __name__ == "__main__":
39
+ unittest.main()
@@ -0,0 +1,183 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import tempfile
5
+ import unittest
6
+ from types import SimpleNamespace
7
+ from unittest.mock import patch
8
+ from pathlib import Path
9
+
10
+ from tvsub_mcp.service import TvsubService, should_use_applescript_position
11
+
12
+
13
+ SRT = """1
14
+ 00:00:10,000 --> 00:00:12,000
15
+ First line
16
+
17
+ 2
18
+ 00:10:00,000 --> 00:10:02,000
19
+ Late line
20
+
21
+ """
22
+
23
+
24
+ class ServiceTests(unittest.TestCase):
25
+ def test_cross_source_threshold_matches_swift_rule(self) -> None:
26
+ self.assertTrue(should_use_applescript_position(35.8, 300.1))
27
+ self.assertFalse(should_use_applescript_position(100.0, 101.999))
28
+ self.assertFalse(should_use_applescript_position(100.0, 102.0))
29
+ self.assertTrue(should_use_applescript_position(100.0, 102.001))
30
+
31
+ def test_live_now_playing_uses_tv_applescript_on_disagreement(self) -> None:
32
+ with tempfile.TemporaryDirectory() as temp:
33
+ root = Path(temp)
34
+ (root / "src").mkdir()
35
+ (root / "src" / "nowplaying.js").write_text("fixture", encoding="utf-8")
36
+ media_remote = SimpleNamespace(
37
+ returncode=0,
38
+ stdout=(
39
+ '{"bundleID":"com.apple.TV","app":"TV",'
40
+ '"kMRMediaRemoteNowPlayingInfoTitle":"Example",'
41
+ '"kMRMediaRemoteNowPlayingInfoElapsedTime":35.8,'
42
+ '"kMRMediaRemoteNowPlayingInfoTimestamp":0,'
43
+ '"kMRMediaRemoteNowPlayingInfoPlaybackRate":1,'
44
+ '"kMRMediaRemoteNowPlayingInfoDuration":1000}'
45
+ ),
46
+ stderr="",
47
+ )
48
+ service = TvsubService(root)
49
+ with patch("tvsub_mcp.service.platform.system", return_value="Darwin"), \
50
+ patch("tvsub_mcp.service.subprocess.run", return_value=media_remote), \
51
+ patch.object(service, "_tv_player_ground_truth", return_value=(300.1, "playing")):
52
+ playback = service.now_playing()
53
+ self.assertTrue(playback["sync_disagreement"])
54
+ self.assertEqual(playback["location_ms"], 300_100)
55
+ self.assertEqual(playback["media_remote_location_ms"], 35_800)
56
+ self.assertEqual(playback["source"], "AppleScript/TV (MediaRemote disagreement)")
57
+
58
+ def test_live_now_playing_quietly_falls_back_when_applescript_fails(self) -> None:
59
+ with tempfile.TemporaryDirectory() as temp:
60
+ root = Path(temp)
61
+ (root / "src").mkdir()
62
+ (root / "src" / "nowplaying.js").write_text("fixture", encoding="utf-8")
63
+ media_remote = SimpleNamespace(
64
+ returncode=0,
65
+ stdout=(
66
+ '{"bundleID":"com.apple.TV",'
67
+ '"kMRMediaRemoteNowPlayingInfoElapsedTime":35.8,'
68
+ '"kMRMediaRemoteNowPlayingInfoTimestamp":0,'
69
+ '"kMRMediaRemoteNowPlayingInfoPlaybackRate":0}'
70
+ ),
71
+ stderr="",
72
+ )
73
+ service = TvsubService(root)
74
+ with patch("tvsub_mcp.service.platform.system", return_value="Darwin"), \
75
+ patch("tvsub_mcp.service.subprocess.run", return_value=media_remote), \
76
+ patch.object(service, "_tv_player_ground_truth", side_effect=subprocess.TimeoutExpired("osascript", 2)):
77
+ playback = service.now_playing()
78
+ self.assertFalse(playback["sync_disagreement"])
79
+ self.assertEqual(playback["location_ms"], 35_800)
80
+ self.assertEqual(playback["source"], "MediaRemote/JXA")
81
+
82
+ def test_calibration_refuses_disagreeing_anchor_without_writing(self) -> None:
83
+ with tempfile.TemporaryDirectory() as temp:
84
+ root = Path(temp)
85
+ (root / "subtitles").mkdir()
86
+ (root / "subtitles" / "sample.srt").write_text(SRT, encoding="utf-8")
87
+ service = TvsubService(root, mock=True)
88
+ service.load_subtitle("sample.srt", "movie-1")
89
+ config_path = root / "config" / "movie-movie-1.json"
90
+ before = config_path.read_text(encoding="utf-8")
91
+ disagreement = {
92
+ "is_playing": False,
93
+ "location_ms": 300_100,
94
+ "sync_disagreement": True,
95
+ "media_remote_location_ms": 35_800,
96
+ "apple_script_location_ms": 300_100,
97
+ }
98
+ with patch.object(service, "now_playing", return_value=disagreement):
99
+ result = service.calibrate_sync("First line", store_id="movie-1")
100
+ self.assertFalse(result["anchor_recorded"])
101
+ self.assertEqual(result["reason"], "mediaremote-applescript-disagreement")
102
+ self.assertEqual(config_path.read_text(encoding="utf-8"), before)
103
+
104
+ def test_mock_flow_and_compatible_movie_config(self) -> None:
105
+ with tempfile.TemporaryDirectory() as temp:
106
+ root = Path(temp)
107
+ (root / "subtitles").mkdir()
108
+ (root / "subtitles" / "sample.srt").write_text(SRT, encoding="utf-8")
109
+ service = TvsubService(root, mock=True)
110
+ self.assertEqual(service.now_playing()["title"], "Example Movie (Mock)")
111
+ self.assertEqual(service.now_playing()["store_id"], "fixture-movie-001")
112
+ listed = service.list_subtitles()
113
+ self.assertEqual(listed["count"], 1)
114
+ self.assertEqual(listed["library"], "subtitles")
115
+ self.assertEqual(listed["subtitles"][0]["path"], "subtitles/sample.srt")
116
+ loaded = service.load_subtitle("sample.srt", "movie-1")
117
+ self.assertTrue(loaded["loaded"])
118
+ self.assertEqual(loaded["subtitle"]["path"], "subtitles/sample.srt")
119
+ first = service.calibrate_sync("First line", store_id="movie-1", actual_time=15)
120
+ second = service.calibrate_sync("Late line", store_id="movie-1", actual_time=620)
121
+ self.assertEqual(second["anchor_count"], 2)
122
+ config = (root / "config" / "movie-movie-1.json").read_text(encoding="utf-8")
123
+ self.assertIn('"subTime"', config)
124
+ self.assertIn('"actualTime"', config)
125
+ started = service.start_overlay(store_id="movie-1")
126
+ self.assertTrue(started["started"])
127
+ self.assertEqual(started["subtitle"], "subtitles/sample.srt")
128
+ self.assertNotIn(str(root), str(service.status()))
129
+ style = service.set_style(
130
+ font_family="Apple SD Gothic Neo", font_size=52,
131
+ bottom_margin=0.09, language="ko",
132
+ )
133
+ self.assertTrue(style["applies_live"])
134
+ self.assertEqual(style["positioning"]["reference"], "tv-video-window")
135
+ self.assertEqual(style["positioning"]["bottom_margin_ratio"], 0.09)
136
+ self.assertEqual(style["positioning"]["fallback"], "configured-screen")
137
+ status = service.status()
138
+ self.assertEqual(status["positioning"], style["positioning"])
139
+ self.assertEqual(status["positioning"]["screen_index_role"], "fallback-only")
140
+ self.assertTrue(service.stop_overlay()["stopped"])
141
+
142
+ def test_start_overlay_movie_override_does_not_bypass_tv_source_filter(self) -> None:
143
+ with tempfile.TemporaryDirectory() as temp:
144
+ root = Path(temp)
145
+ (root / "subtitles").mkdir()
146
+ (root / "subtitles" / "sample.srt").write_text(SRT, encoding="utf-8")
147
+ service = TvsubService(root, mock=True)
148
+ service.load_subtitle("sample.srt", "movie-1")
149
+
150
+ for bundle_id in ("com.apple.Music", ""):
151
+ with self.subTest(bundle_id=bundle_id or "missing"):
152
+ with patch.dict(
153
+ "os.environ",
154
+ {"TVSUB_MOCK_APP": "Music", "TVSUB_MOCK_BUNDLE_ID": bundle_id},
155
+ ):
156
+ with self.assertRaisesRegex(RuntimeError, "소스 검사는 우회하지 않습니다"):
157
+ service.start_overlay(store_id="movie-1")
158
+ self.assertFalse(service.overlay_state_path.exists())
159
+
160
+ def test_missing_subtitle_error_does_not_expose_root(self) -> None:
161
+ with tempfile.TemporaryDirectory() as temp:
162
+ root = Path(temp)
163
+ service = TvsubService(root, mock=True)
164
+ with self.assertRaises(FileNotFoundError) as caught:
165
+ service.load_subtitle("missing.srt", "movie-1")
166
+ self.assertNotIn(str(root), str(caught.exception))
167
+ self.assertIn("subtitles/missing.srt", str(caught.exception))
168
+
169
+ def test_parse_error_does_not_expose_root(self) -> None:
170
+ with tempfile.TemporaryDirectory() as temp:
171
+ root = Path(temp)
172
+ (root / "subtitles").mkdir()
173
+ (root / "subtitles" / "broken.srt").write_text("not a subtitle", encoding="utf-8")
174
+ service = TvsubService(root, mock=True)
175
+ listed = service.list_subtitles()
176
+ self.assertNotIn(str(root), str(listed))
177
+ with self.assertRaises(ValueError) as caught:
178
+ service.load_subtitle("broken.srt", "movie-1")
179
+ self.assertNotIn(str(root), str(caught.exception))
180
+
181
+
182
+ if __name__ == "__main__":
183
+ unittest.main()
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+
7
+ from tvsub_mcp.subtitles import load_subtitle, validate_timeline, write_srt
8
+
9
+
10
+ SRT = """1
11
+ 00:00:01,000 --> 00:00:02,500
12
+ <i>Hello</i>
13
+
14
+ 2
15
+ 00:01:05.25 --> 00:01:07.750
16
+ Second line
17
+
18
+ """
19
+
20
+
21
+ class SubtitleTests(unittest.TestCase):
22
+ def test_srt_preserves_timecodes_and_count(self) -> None:
23
+ with tempfile.TemporaryDirectory() as temp:
24
+ source = Path(temp) / "sample.srt"
25
+ output = Path(temp) / "sample.ko.srt"
26
+ source.write_text(SRT, encoding="utf-8")
27
+ document = load_subtitle(source)
28
+ self.assertEqual(document.encoding, "UTF-8")
29
+ self.assertEqual(document.format, "srt")
30
+ self.assertEqual(len(document.cues), 2)
31
+ self.assertEqual(document.cues[0].timecode, "00:00:01,000 --> 00:00:02,500")
32
+ self.assertEqual(document.cues[0].text, "Hello")
33
+ write_srt(document.cues, {1: "안녕", 2: "둘째 줄"}, output)
34
+ translated = load_subtitle(output)
35
+ validate_timeline(document, translated)
36
+
37
+ def test_cp949_smi_selects_korean_track(self) -> None:
38
+ smi = """<SAMI><BODY>
39
+ <SYNC Start=1000><P Class=KRCC>첫째<br>줄
40
+ <SYNC Start=2000><P Class=KRCC>&nbsp;
41
+ <SYNC Start=1000><P Class=ENCC>First line
42
+ <SYNC Start=2000><P Class=ENCC>&nbsp;
43
+ </BODY></SAMI>"""
44
+ with tempfile.TemporaryDirectory() as temp:
45
+ source = Path(temp) / "sample.smi"
46
+ source.write_bytes(smi.encode("cp949"))
47
+ document = load_subtitle(source)
48
+ self.assertEqual(document.encoding, "CP949")
49
+ self.assertEqual(document.selected_class, "KRCC")
50
+ self.assertEqual(len(document.cues), 1)
51
+ self.assertEqual(document.cues[0].lines, ["첫째", "줄"])
52
+ self.assertEqual(document.cues[0].end, 2.0)
53
+
54
+
55
+ if __name__ == "__main__":
56
+ unittest.main()
57
+
@@ -0,0 +1,4 @@
1
+ """tvsub MCP server package."""
2
+
3
+ __version__ = "0.1.0"
4
+
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(slots=True)
7
+ class Calibration:
8
+ scale: float
9
+ offset: float
10
+ warning: str | None = None
11
+
12
+
13
+ def calculate(anchors: list[dict]) -> Calibration:
14
+ ordered = sorted(anchors, key=lambda item: float(item["subTime"]))
15
+ if not ordered:
16
+ return Calibration(1.0, 0.0)
17
+ if len(ordered) == 1:
18
+ return Calibration(1.0, float(ordered[0]["actualTime"]) - float(ordered[0]["subTime"]))
19
+ xs = [float(item["subTime"]) for item in ordered]
20
+ ys = [float(item["actualTime"]) for item in ordered]
21
+ n = len(xs)
22
+ sx, sy = sum(xs), sum(ys)
23
+ sxx = sum(value * value for value in xs)
24
+ sxy = sum(x * y for x, y in zip(xs, ys))
25
+ denominator = n * sxx - sx * sx
26
+ if abs(denominator) < 1e-9:
27
+ scale = 1.0
28
+ offset = ys[0] - xs[0]
29
+ else:
30
+ scale = (n * sxy - sx * sy) / denominator
31
+ offset = (sy - scale * sx) / n
32
+ span = max(xs) - min(xs)
33
+ warning = None
34
+ if span < 300:
35
+ warning = f"앵커 간격이 {span:.0f}초뿐이라 scale 추정이 불안정합니다."
36
+ elif scale < 0.9 or scale > 1.15:
37
+ warning = f"scale={scale:.4f}가 정상 범위(0.9~1.15)를 벗어났습니다."
38
+ return Calibration(scale, offset, warning)
39
+