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.
- tvsub_mcp-0.1.0/LICENSE +22 -0
- tvsub_mcp-0.1.0/PKG-INFO +129 -0
- tvsub_mcp-0.1.0/README.md +108 -0
- tvsub_mcp-0.1.0/pyproject.toml +36 -0
- tvsub_mcp-0.1.0/setup.cfg +4 -0
- tvsub_mcp-0.1.0/tests/test_calibration.py +39 -0
- tvsub_mcp-0.1.0/tests/test_server.py +39 -0
- tvsub_mcp-0.1.0/tests/test_service.py +183 -0
- tvsub_mcp-0.1.0/tests/test_subtitles.py +57 -0
- tvsub_mcp-0.1.0/tvsub_mcp/__init__.py +4 -0
- tvsub_mcp-0.1.0/tvsub_mcp/calibration.py +39 -0
- tvsub_mcp-0.1.0/tvsub_mcp/helpers/list-fonts.swift +34 -0
- tvsub_mcp-0.1.0/tvsub_mcp/server.py +149 -0
- tvsub_mcp-0.1.0/tvsub_mcp/service.py +659 -0
- tvsub_mcp-0.1.0/tvsub_mcp/subtitles.py +235 -0
- tvsub_mcp-0.1.0/tvsub_mcp/translation.py +206 -0
- tvsub_mcp-0.1.0/tvsub_mcp.egg-info/PKG-INFO +129 -0
- tvsub_mcp-0.1.0/tvsub_mcp.egg-info/SOURCES.txt +20 -0
- tvsub_mcp-0.1.0/tvsub_mcp.egg-info/dependency_links.txt +1 -0
- tvsub_mcp-0.1.0/tvsub_mcp.egg-info/entry_points.txt +2 -0
- tvsub_mcp-0.1.0/tvsub_mcp.egg-info/requires.txt +2 -0
- tvsub_mcp-0.1.0/tvsub_mcp.egg-info/top_level.txt +1 -0
tvsub_mcp-0.1.0/LICENSE
ADDED
|
@@ -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
|
+
|
tvsub_mcp-0.1.0/PKG-INFO
ADDED
|
@@ -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,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>
|
|
41
|
+
<SYNC Start=1000><P Class=ENCC>First line
|
|
42
|
+
<SYNC Start=2000><P Class=ENCC>
|
|
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,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
|
+
|