parlova 1.0.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,3 @@
1
+ dist/
2
+ __pycache__/
3
+ *.egg-info/
parlova-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SoleCreations
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.
parlova-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: parlova
3
+ Version: 1.0.0
4
+ Summary: Transcribe audio, video and YouTube from your terminal or from Claude, Cursor and VS Code (MCP server).
5
+ Project-URL: Homepage, https://parlova.co.za
6
+ Project-URL: Source, https://github.com/fort652/parlova-mcp
7
+ Author: SoleCreations
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: afrikaans,claude,cursor,mcp,model-context-protocol,speech-to-text,subtitles,transcription,youtube
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
16
+ Requires-Python: >=3.8
17
+ Description-Content-Type: text/markdown
18
+
19
+ # Parlova MCP: transcribe audio, video and YouTube from Claude, Cursor and VS Code
20
+
21
+ Parlova lets your AI assistant turn local recordings and YouTube videos into accurate, timestamped transcripts, with
22
+ speakers labelled, so you can summarise, search and quote them without leaving the chat.
23
+
24
+ It runs as a local MCP server (`parlova mcp`) that talks to the [Parlova](https://parlova.co.za) transcription API, powered
25
+ by ElevenLabs Scribe v2: 90+ languages with automatic detection, including Afrikaans and isiZulu, even when languages are
26
+ mixed in one recording. It's also a handy command-line tool.
27
+
28
+ ## Prerequisites
29
+
30
+ - Python 3.8 or newer. There are no dependencies; it's a single file using only the standard library.
31
+ - A Parlova **Plus** ($5/month) or **Pro** ($10/month) account. Developer access (API, CLI, MCP) isn't on the Free plan.
32
+ [Try the quality free on the web first](https://parlova.co.za).
33
+
34
+ ## Quickstart (about 1 minute)
35
+
36
+ **1. Install** (pick one)
37
+
38
+ ```sh
39
+ uv tool install parlova # or: pipx install parlova
40
+ pip install --user parlova
41
+
42
+ # no Python packaging tools? (macOS / Linux)
43
+ curl -fsSL https://parlova.co.za/cli/install.sh | sh
44
+ # Windows PowerShell
45
+ irm https://parlova.co.za/cli/install.ps1 | iex
46
+ ```
47
+
48
+ **2. Sign in.** Your browser opens; check the code and click Approve.
49
+
50
+ ```sh
51
+ parlova login
52
+ ```
53
+
54
+ **3. Connect your AI app**
55
+
56
+ ```sh
57
+ parlova mcp-config # Claude Desktop → paste into claude_desktop_config.json
58
+ parlova mcp-config --app cursor # Cursor → ~/.cursor/mcp.json
59
+ parlova mcp-config --app vscode # VS Code → .vscode/mcp.json
60
+ parlova mcp-config --app claude-code # Claude Code → prints a `claude mcp add …` command to run
61
+ ```
62
+
63
+ `mcp-config` prints the config with full paths for your machine. Desktop apps don't see your shell's PATH, so a bare
64
+ `"command": "parlova"` usually fails. Restart the app after editing its config.
65
+
66
+ If you have [uv](https://docs.astral.sh/uv/), this config works anywhere without installing first:
67
+
68
+ ```json
69
+ {
70
+ "mcpServers": {
71
+ "parlova": { "command": "uvx", "args": ["parlova", "mcp"] }
72
+ }
73
+ }
74
+ ```
75
+
76
+ (Run `uvx parlova login` once first, or add `"env": {"PARLOVA_TOKEN": "…"}` with a token from Settings → Developers.)
77
+
78
+ ## Tools
79
+
80
+ | Tool | What it does | Arguments |
81
+ |---|---|---|
82
+ | `transcribe_file` | Transcribes an audio or video file on your computer and returns the text. Waits for the result; if it takes longer, returns a job id. | `path` (absolute), `language` (`auto` default, or e.g. `af`, `en`), `speakers` (bool), `max_wait_seconds` (default 240) |
83
+ | `transcribe_youtube` | Same, for a YouTube link | `url`, `language`, `speakers`, `max_wait_seconds` |
84
+ | `get_transcript` | Fetches a finished transcript by job id | `job_id`, `format`: `text` (readable, timestamps + speakers), `txt`, `srt`, `vtt`, `csv`, `md`, `json` |
85
+ | `list_transcripts` | Recent transcriptions with ids, status, dates and titles | `limit` (default 20) |
86
+ | `get_usage` | Minutes used this month, your plan's limit, recent months | — |
87
+
88
+ Speaker labels need Plus or Pro. `md` and `json` downloads are Pro. Plan limits: Plus is 10 hours a month with recordings up
89
+ to 1 hour; Pro is 30 hours a month with recordings up to 3 hours.
90
+
91
+ ## Example prompts
92
+
93
+ 1. "Transcribe ~/Downloads/team-meeting.m4a with speakers and list the action items with who owns each one."
94
+ 2. "Transcribe this YouTube talk and give me the five key ideas with timestamps: https://www.youtube.com/watch?v=…"
95
+ 3. "Transcribe ~/Desktop/interview.mp3. It's mostly Afrikaans with some English. Then translate it to English, keeping the
96
+ speaker labels."
97
+ 4. "Get the SRT subtitles for my last transcript and save them next to the video as talk.srt."
98
+ 5. "How many transcription minutes have I used this month, and what were my last five transcripts about?"
99
+
100
+ ## Command line
101
+
102
+ ```sh
103
+ parlova transcribe meeting.m4a --speakers
104
+ parlova transcribe "https://youtu.be/…" --format srt -o talk.srt
105
+ parlova list
106
+ parlova get <job-id> --format vtt
107
+ parlova usage
108
+ ```
109
+
110
+ For servers and CI: `parlova login --token` or set `PARLOVA_TOKEN`.
111
+
112
+ ## Privacy
113
+
114
+ Audio is only used to make your transcript. Uploaded audio is deleted once it's transcribed, and our speech providers don't
115
+ store it or train on it. You can delete any transcript at any time. [Privacy policy](https://parlova.co.za/privacy)
116
+
117
+ ## License
118
+
119
+ MIT
@@ -0,0 +1,101 @@
1
+ # Parlova MCP: transcribe audio, video and YouTube from Claude, Cursor and VS Code
2
+
3
+ Parlova lets your AI assistant turn local recordings and YouTube videos into accurate, timestamped transcripts, with
4
+ speakers labelled, so you can summarise, search and quote them without leaving the chat.
5
+
6
+ It runs as a local MCP server (`parlova mcp`) that talks to the [Parlova](https://parlova.co.za) transcription API, powered
7
+ by ElevenLabs Scribe v2: 90+ languages with automatic detection, including Afrikaans and isiZulu, even when languages are
8
+ mixed in one recording. It's also a handy command-line tool.
9
+
10
+ ## Prerequisites
11
+
12
+ - Python 3.8 or newer. There are no dependencies; it's a single file using only the standard library.
13
+ - A Parlova **Plus** ($5/month) or **Pro** ($10/month) account. Developer access (API, CLI, MCP) isn't on the Free plan.
14
+ [Try the quality free on the web first](https://parlova.co.za).
15
+
16
+ ## Quickstart (about 1 minute)
17
+
18
+ **1. Install** (pick one)
19
+
20
+ ```sh
21
+ uv tool install parlova # or: pipx install parlova
22
+ pip install --user parlova
23
+
24
+ # no Python packaging tools? (macOS / Linux)
25
+ curl -fsSL https://parlova.co.za/cli/install.sh | sh
26
+ # Windows PowerShell
27
+ irm https://parlova.co.za/cli/install.ps1 | iex
28
+ ```
29
+
30
+ **2. Sign in.** Your browser opens; check the code and click Approve.
31
+
32
+ ```sh
33
+ parlova login
34
+ ```
35
+
36
+ **3. Connect your AI app**
37
+
38
+ ```sh
39
+ parlova mcp-config # Claude Desktop → paste into claude_desktop_config.json
40
+ parlova mcp-config --app cursor # Cursor → ~/.cursor/mcp.json
41
+ parlova mcp-config --app vscode # VS Code → .vscode/mcp.json
42
+ parlova mcp-config --app claude-code # Claude Code → prints a `claude mcp add …` command to run
43
+ ```
44
+
45
+ `mcp-config` prints the config with full paths for your machine. Desktop apps don't see your shell's PATH, so a bare
46
+ `"command": "parlova"` usually fails. Restart the app after editing its config.
47
+
48
+ If you have [uv](https://docs.astral.sh/uv/), this config works anywhere without installing first:
49
+
50
+ ```json
51
+ {
52
+ "mcpServers": {
53
+ "parlova": { "command": "uvx", "args": ["parlova", "mcp"] }
54
+ }
55
+ }
56
+ ```
57
+
58
+ (Run `uvx parlova login` once first, or add `"env": {"PARLOVA_TOKEN": "…"}` with a token from Settings → Developers.)
59
+
60
+ ## Tools
61
+
62
+ | Tool | What it does | Arguments |
63
+ |---|---|---|
64
+ | `transcribe_file` | Transcribes an audio or video file on your computer and returns the text. Waits for the result; if it takes longer, returns a job id. | `path` (absolute), `language` (`auto` default, or e.g. `af`, `en`), `speakers` (bool), `max_wait_seconds` (default 240) |
65
+ | `transcribe_youtube` | Same, for a YouTube link | `url`, `language`, `speakers`, `max_wait_seconds` |
66
+ | `get_transcript` | Fetches a finished transcript by job id | `job_id`, `format`: `text` (readable, timestamps + speakers), `txt`, `srt`, `vtt`, `csv`, `md`, `json` |
67
+ | `list_transcripts` | Recent transcriptions with ids, status, dates and titles | `limit` (default 20) |
68
+ | `get_usage` | Minutes used this month, your plan's limit, recent months | — |
69
+
70
+ Speaker labels need Plus or Pro. `md` and `json` downloads are Pro. Plan limits: Plus is 10 hours a month with recordings up
71
+ to 1 hour; Pro is 30 hours a month with recordings up to 3 hours.
72
+
73
+ ## Example prompts
74
+
75
+ 1. "Transcribe ~/Downloads/team-meeting.m4a with speakers and list the action items with who owns each one."
76
+ 2. "Transcribe this YouTube talk and give me the five key ideas with timestamps: https://www.youtube.com/watch?v=…"
77
+ 3. "Transcribe ~/Desktop/interview.mp3. It's mostly Afrikaans with some English. Then translate it to English, keeping the
78
+ speaker labels."
79
+ 4. "Get the SRT subtitles for my last transcript and save them next to the video as talk.srt."
80
+ 5. "How many transcription minutes have I used this month, and what were my last five transcripts about?"
81
+
82
+ ## Command line
83
+
84
+ ```sh
85
+ parlova transcribe meeting.m4a --speakers
86
+ parlova transcribe "https://youtu.be/…" --format srt -o talk.srt
87
+ parlova list
88
+ parlova get <job-id> --format vtt
89
+ parlova usage
90
+ ```
91
+
92
+ For servers and CI: `parlova login --token` or set `PARLOVA_TOKEN`.
93
+
94
+ ## Privacy
95
+
96
+ Audio is only used to make your transcript. Uploaded audio is deleted once it's transcribed, and our speech providers don't
97
+ store it or train on it. You can delete any transcript at any time. [Privacy policy](https://parlova.co.za/privacy)
98
+
99
+ ## License
100
+
101
+ MIT
@@ -0,0 +1,510 @@
1
+ #!/usr/bin/env python3
2
+ """Parlova command-line tool and MCP server.
3
+
4
+ Transcribe recordings and YouTube links from your terminal, or let AI apps (Claude Desktop,
5
+ Claude Code, Cursor, VS Code…) do it through MCP. Needs Python 3.8+ and nothing else.
6
+
7
+ parlova login sign in through your browser (no token to copy)
8
+ parlova transcribe meeting.mp3 upload, wait, print the transcript
9
+ parlova transcribe URL --speakers --format srt -o talk.srt
10
+ parlova list your recent transcriptions
11
+ parlova get JOB_ID --format docx -o notes.docx
12
+ parlova usage minutes used this month
13
+ parlova mcp-config print the MCP config for Claude Desktop, Cursor, VS Code…
14
+ parlova mcp run as an MCP server (stdio)
15
+
16
+ `parlova login` stores the token in ~/.config/parlova/config.json, readable only by you.
17
+ For servers and CI, use `parlova login --token …` or set PARLOVA_TOKEN instead.
18
+ """
19
+
20
+ import argparse
21
+ import getpass
22
+ import json
23
+ import os
24
+ import platform
25
+ import sys
26
+ import time
27
+ import webbrowser
28
+ import urllib.error
29
+ import urllib.parse
30
+ import urllib.request
31
+ from pathlib import Path
32
+
33
+ VERSION = "1.0.0"
34
+ DEFAULT_URL = "https://parlova.co.za" # filled in by the site you download this from
35
+ CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
36
+ CONFIG = CONFIG_DIR / "parlova" / "config.json"
37
+ OLD_CONFIG = CONFIG_DIR / "anyscribe" / "config.json" # before the rename to Parlova
38
+ FORMATS = ("txt", "srt", "vtt", "csv", "md", "json", "docx", "pdf")
39
+ TEXT_FORMATS = ("txt", "srt", "vtt", "csv", "md", "json")
40
+ FINISHED = ("completed", "failed", "cancelled")
41
+
42
+
43
+ class ApiError(Exception):
44
+ pass
45
+
46
+
47
+ # --------------------------------------------------------------------------- #
48
+ # Settings and HTTP
49
+ # --------------------------------------------------------------------------- #
50
+
51
+ def load_config() -> dict:
52
+ for path in (CONFIG, OLD_CONFIG): # a sign-in saved before the rename still works
53
+ try:
54
+ return json.loads(path.read_text())
55
+ except (OSError, ValueError):
56
+ continue
57
+ return {}
58
+
59
+
60
+ def save_config(data: dict) -> None:
61
+ CONFIG.parent.mkdir(parents=True, exist_ok=True)
62
+ CONFIG.write_text(json.dumps(data, indent=2))
63
+ try:
64
+ CONFIG.chmod(0o600) # the token is a secret
65
+ except OSError:
66
+ pass
67
+
68
+
69
+ def base_url() -> str:
70
+ url = os.environ.get("PARLOVA_URL") or os.environ.get("ANYSCRIBE_URL") or load_config().get("url") or DEFAULT_URL
71
+ if url.startswith("__"):
72
+ raise ApiError("No Parlova address set. Run: parlova login --url https://your-parlova-site")
73
+ return url.rstrip("/")
74
+
75
+
76
+ def token() -> str:
77
+ value = os.environ.get("PARLOVA_TOKEN") or os.environ.get("ANYSCRIBE_TOKEN") or load_config().get("token")
78
+ if not value:
79
+ raise ApiError("Not logged in. Create a token in Settings > Developers, then run: parlova login")
80
+ return value
81
+
82
+
83
+ def request(method: str, path: str, *, body=None, headers=None, raw: bool = False, length: int = None, auth=None):
84
+ """Call the API. Returns parsed JSON, or bytes when raw=True. Raises ApiError with the server's message.
85
+ auth: a token to use instead of the saved one, or False for the sign-in endpoints that need none."""
86
+ req = urllib.request.Request(base_url() + path, data=body, method=method)
87
+ if auth is not False:
88
+ req.add_header("Authorization", f"Bearer {auth or token()}")
89
+ req.add_header("User-Agent", f"parlova-cli/{VERSION}")
90
+ if length is not None:
91
+ req.add_header("Content-Length", str(length))
92
+ for key, value in (headers or {}).items():
93
+ req.add_header(key, value)
94
+ try:
95
+ with urllib.request.urlopen(req, timeout=600) as response:
96
+ data = response.read()
97
+ except urllib.error.HTTPError as exc:
98
+ detail = exc.read().decode("utf-8", "replace")
99
+ try:
100
+ detail = json.loads(detail).get("error") or detail
101
+ except ValueError:
102
+ pass
103
+ error = ApiError(f"{detail} (HTTP {exc.code})")
104
+ error.status = exc.code
105
+ raise error from None
106
+ except urllib.error.URLError as exc:
107
+ raise ApiError(f"Couldn't reach {base_url()}: {exc.reason}") from None
108
+ return data if raw else json.loads(data or b"null")
109
+
110
+
111
+ def query(**params) -> str:
112
+ return urllib.parse.urlencode({k: ("true" if v is True else v) for k, v in params.items() if v not in (None, False)})
113
+
114
+
115
+ # --------------------------------------------------------------------------- #
116
+ # Transcription helpers (shared by the CLI and the MCP server)
117
+ # --------------------------------------------------------------------------- #
118
+
119
+ def is_url(value: str) -> bool:
120
+ return value.startswith(("http://", "https://", "www.", "youtu.be/", "youtube.com/"))
121
+
122
+
123
+ def submit(source: str, language: str = "auto", speakers: bool = False) -> dict:
124
+ """Start a transcription for a local file or a YouTube link. Returns the new job."""
125
+ if is_url(source):
126
+ url = source if source.startswith("http") else "https://" + source
127
+ body = json.dumps({"urls": [url], "language": language, "diarize": speakers}).encode()
128
+ return request("POST", "/api/jobs", body=body, headers={"Content-Type": "application/json"})[0]
129
+ path = Path(source).expanduser()
130
+ if not path.is_file():
131
+ raise ApiError(f"File not found: {source}")
132
+ with path.open("rb") as handle:
133
+ params = query(filename=path.name, language=language, diarize=speakers)
134
+ return request("POST", f"/api/jobs/upload?{params}", body=handle, length=path.stat().st_size,
135
+ headers={"Content-Type": "application/octet-stream"})[0]
136
+
137
+
138
+ def wait(job_id: str, timeout: float = None, on_progress=None) -> dict:
139
+ """Poll until the job finishes (or the timeout passes). Returns the latest job."""
140
+ started = time.time()
141
+ while True:
142
+ job = request("GET", f"/api/jobs/{job_id}")
143
+ if job["status"] in FINISHED:
144
+ return job
145
+ if on_progress:
146
+ on_progress(job)
147
+ if timeout is not None and time.time() - started > timeout:
148
+ return job
149
+ time.sleep(3)
150
+
151
+
152
+ def clock(ms) -> str:
153
+ seconds = int((ms or 0) // 1000)
154
+ return f"{seconds // 3600:02d}:{seconds % 3600 // 60:02d}:{seconds % 60:02d}"
155
+
156
+
157
+ def readable(job: dict) -> str:
158
+ """Transcript with timestamps and speaker names when the plan includes them."""
159
+ names = job.get("speakerNames") or {}
160
+ lines = []
161
+ for p in job.get("paragraphs") or []:
162
+ prefix = f"[{clock(p['start'])}] " if "start" in p else ""
163
+ speaker = p.get("speaker")
164
+ if speaker is not None:
165
+ prefix += f"{names.get(str(speaker)) or f'Speaker {int(speaker) + 1 if str(speaker).isdigit() else speaker}'}: "
166
+ lines.append(prefix + p.get("text", ""))
167
+ return "\n\n".join(lines) or job.get("text") or ""
168
+
169
+
170
+ # --------------------------------------------------------------------------- #
171
+ # CLI commands
172
+ # --------------------------------------------------------------------------- #
173
+
174
+ def cmd_login(args) -> None:
175
+ config = load_config()
176
+ if args.url:
177
+ config["url"] = args.url.rstrip("/")
178
+ save_config(config)
179
+ if args.token is not None: # servers/CI: a token made under Settings > Developers > Access tokens
180
+ value = args.token or getpass.getpass("Paste your access token: ").strip()
181
+ else:
182
+ value = browser_login(open_browser=not args.no_browser)
183
+ if not value:
184
+ raise ApiError("No token given.")
185
+ usage = request("GET", "/api/usage", auth=value) # check it works before saving
186
+ config["token"] = value
187
+ save_config(config)
188
+ print(f"Logged in to {base_url()} ({usage['planName']} plan). Saved to {CONFIG} (only you can read it).")
189
+
190
+
191
+ def browser_login(open_browser: bool = True) -> str:
192
+ """Sign in by approving this computer in the browser. Returns the new token."""
193
+ name = (platform.node() or "my computer").split(".")[0]
194
+ body = json.dumps({"client_name": name}).encode()
195
+ start = request("POST", "/api/cli/login/start", body=body, headers={"Content-Type": "application/json"}, auth=False)
196
+ url, code = start["verificationUrl"], start["userCode"]
197
+ print(f"Your sign-in code: {code}", flush=True)
198
+ if open_browser and webbrowser.open(url):
199
+ print("Opened your browser. Check the code matches, then click Approve.", flush=True)
200
+ else:
201
+ print(f"Open this link in your browser, check the code matches, then click Approve:\n {url}", flush=True)
202
+ print("Waiting for approval… (Ctrl+C to cancel)", flush=True)
203
+ deadline = time.time() + start["expiresIn"]
204
+ poll = json.dumps({"device_code": start["deviceCode"]}).encode()
205
+ while time.time() < deadline:
206
+ time.sleep(start.get("interval", 3))
207
+ try:
208
+ result = request("POST", "/api/cli/login/poll", body=poll, headers={"Content-Type": "application/json"}, auth=False)
209
+ except ApiError as exc:
210
+ if getattr(exc, "status", None) == 429:
211
+ continue
212
+ raise
213
+ if result["status"] == "approved":
214
+ return result["token"]
215
+ raise ApiError("Sign-in timed out. Run parlova login again.")
216
+
217
+
218
+ def cmd_logout(args) -> None:
219
+ config = load_config()
220
+ config.pop("token", None)
221
+ save_config(config)
222
+ print("Logged out on this computer. To switch the token off completely, revoke it under "
223
+ "Settings > Developers > Access tokens.")
224
+
225
+
226
+ def cmd_transcribe(args) -> None:
227
+ fmt = args.format
228
+ for source in args.sources:
229
+ job = submit(source, args.language, args.speakers)
230
+ if args.no_wait:
231
+ print(job["id"])
232
+ continue
233
+ print(f"Transcribing {job.get('title') or job.get('filename') or source} (job {job['id']})…", file=sys.stderr)
234
+
235
+ def progress(j):
236
+ print(f"\r {j.get('stage') or j['status']} {int(j.get('progress') or 0)}% ", end="", file=sys.stderr)
237
+
238
+ job = wait(job["id"], on_progress=progress)
239
+ print("\r" + " " * 40 + "\r", end="", file=sys.stderr)
240
+ if job["status"] != "completed":
241
+ raise ApiError(f"{source}: {job.get('error') or job['status']}")
242
+ output(job, fmt, args.output if len(args.sources) == 1 else None)
243
+
244
+
245
+ def output(job: dict, fmt: str, path: str = None) -> None:
246
+ if fmt in (None, "text"):
247
+ text = readable(job)
248
+ if path:
249
+ Path(path).write_text(text + "\n")
250
+ print(f"Saved {path}", file=sys.stderr)
251
+ else:
252
+ print(text)
253
+ return
254
+ data = request("GET", f"/api/jobs/{job['id']}/file/{fmt}", raw=True)
255
+ if not path and fmt not in TEXT_FORMATS:
256
+ path = f"{Path(job.get('title') or job['id']).name}.{fmt}"
257
+ if path:
258
+ Path(path).write_bytes(data)
259
+ print(f"Saved {path}", file=sys.stderr)
260
+ else:
261
+ sys.stdout.write(data.decode("utf-8", "replace"))
262
+
263
+
264
+ def cmd_list(args) -> None:
265
+ jobs = request("GET", "/api/jobs")[: args.limit]
266
+ if not jobs:
267
+ print("No transcriptions yet.")
268
+ return
269
+ for job in jobs:
270
+ title = job.get("title") or job.get("filename") or job.get("url") or ""
271
+ when = (job.get("createdAt") or "")[:10]
272
+ length = clock((job.get("duration") or 0) * 1000)
273
+ print(f"{job['id']} {job['status']:<9} {when} {length} {title}")
274
+
275
+
276
+ def cmd_get(args) -> None:
277
+ job = request("GET", f"/api/jobs/{args.job_id}")
278
+ if job["status"] != "completed":
279
+ raise ApiError(f"Job {args.job_id} is {job['status']}" + (f": {job['error']}" if job.get("error") else ""))
280
+ output(job, args.format, args.output)
281
+
282
+
283
+ def cmd_usage(args) -> None:
284
+ usage = request("GET", "/api/usage")
285
+ now = usage["thisMonth"]
286
+ print(f"{usage['planName']} plan: {now['usedMinutes']} of {now['limitMinutes']} minutes used this month "
287
+ f"({now['remainingMinutes']} left, resets {now['resetsOn']}).")
288
+ for month in usage["months"]:
289
+ if month["recordings"]:
290
+ print(f" {month['month']}: {month['minutes']} min, {month['recordings']} recording(s)")
291
+
292
+
293
+ # --------------------------------------------------------------------------- #
294
+ # MCP server (Model Context Protocol over stdio, JSON-RPC 2.0, one message per line)
295
+ # --------------------------------------------------------------------------- #
296
+
297
+ MCP_TOOLS = [
298
+ {
299
+ "name": "transcribe_file",
300
+ "description": "Transcribe an audio or video file on this computer with Parlova and return the text. "
301
+ "Waits for the result (up to max_wait_seconds); if it isn't ready by then, returns the job id "
302
+ "to check later with get_transcript.",
303
+ "inputSchema": {
304
+ "type": "object",
305
+ "properties": {
306
+ "path": {"type": "string", "description": "Absolute path to the audio or video file."},
307
+ "language": {"type": "string", "description": "Spoken language code such as 'en', or 'auto' (default)."},
308
+ "speakers": {"type": "boolean", "description": "Label who said what (Plus/Pro)."},
309
+ "max_wait_seconds": {"type": "integer", "description": "How long to wait for the result (default 240)."},
310
+ },
311
+ "required": ["path"],
312
+ },
313
+ },
314
+ {
315
+ "name": "transcribe_youtube",
316
+ "description": "Transcribe a YouTube video by its link and return the text. Waits like transcribe_file.",
317
+ "inputSchema": {
318
+ "type": "object",
319
+ "properties": {
320
+ "url": {"type": "string", "description": "YouTube link."},
321
+ "language": {"type": "string", "description": "Spoken language code, or 'auto' (default)."},
322
+ "speakers": {"type": "boolean", "description": "Label who said what (Plus/Pro)."},
323
+ "max_wait_seconds": {"type": "integer", "description": "How long to wait for the result (default 240)."},
324
+ },
325
+ "required": ["url"],
326
+ },
327
+ },
328
+ {
329
+ "name": "get_transcript",
330
+ "description": "Get a transcript by job id: readable text with timestamps and speakers, or a file format "
331
+ "(srt, vtt, csv, md, json, txt).",
332
+ "inputSchema": {
333
+ "type": "object",
334
+ "properties": {
335
+ "job_id": {"type": "string"},
336
+ "format": {"type": "string", "enum": ["text", *TEXT_FORMATS], "description": "Default: text."},
337
+ },
338
+ "required": ["job_id"],
339
+ },
340
+ },
341
+ {
342
+ "name": "list_transcripts",
343
+ "description": "List recent Parlova transcriptions with their ids, status, dates and titles.",
344
+ "inputSchema": {"type": "object", "properties": {"limit": {"type": "integer", "description": "Default 20."}}},
345
+ },
346
+ {
347
+ "name": "get_usage",
348
+ "description": "Minutes of transcription used this month, the plan's limit, and recent monthly totals.",
349
+ "inputSchema": {"type": "object", "properties": {}},
350
+ },
351
+ ]
352
+
353
+
354
+ def mcp_transcribe(source: str, args: dict) -> str:
355
+ job = submit(source, args.get("language") or "auto", bool(args.get("speakers")))
356
+ job = wait(job["id"], timeout=float(args.get("max_wait_seconds") or 240))
357
+ if job["status"] == "completed":
358
+ title = job.get("title") or job.get("filename") or source
359
+ return f"Transcript of {title} (job {job['id']}):\n\n{readable(job)}"
360
+ if job["status"] in FINISHED:
361
+ raise ApiError(job.get("error") or f"Transcription {job['status']}.")
362
+ return (f"Still transcribing (job {job['id']}, {int(job.get('progress') or 0)}% done). "
363
+ f"Call get_transcript with job_id '{job['id']}' in a minute.")
364
+
365
+
366
+ def mcp_call(name: str, args: dict) -> str:
367
+ if name == "transcribe_file":
368
+ return mcp_transcribe(args["path"], args)
369
+ if name == "transcribe_youtube":
370
+ return mcp_transcribe(args["url"], args)
371
+ if name == "get_transcript":
372
+ job = request("GET", f"/api/jobs/{args['job_id']}")
373
+ if job["status"] != "completed":
374
+ return f"Job {job['id']} is {job['status']}" + (f": {job['error']}" if job.get("error") else f" ({int(job.get('progress') or 0)}% done).")
375
+ fmt = args.get("format") or "text"
376
+ if fmt == "text":
377
+ return readable(job)
378
+ return request("GET", f"/api/jobs/{job['id']}/file/{fmt}", raw=True).decode("utf-8", "replace")
379
+ if name == "list_transcripts":
380
+ jobs = request("GET", "/api/jobs")[: int(args.get("limit") or 20)]
381
+ return "\n".join(
382
+ f"{j['id']} | {j['status']} | {(j.get('createdAt') or '')[:10]} | {j.get('title') or j.get('filename') or j.get('url')}"
383
+ for j in jobs
384
+ ) or "No transcriptions yet."
385
+ if name == "get_usage":
386
+ u = request("GET", "/api/usage")
387
+ now = u["thisMonth"]
388
+ lines = [f"{u['planName']} plan: {now['usedMinutes']} of {now['limitMinutes']} minutes used this month, "
389
+ f"{now['remainingMinutes']} left, resets {now['resetsOn']}."]
390
+ lines += [f"{m['month']}: {m['minutes']} min ({m['recordings']} recordings)" for m in u["months"] if m["recordings"]]
391
+ return "\n".join(lines)
392
+ raise ApiError(f"Unknown tool: {name}")
393
+
394
+
395
+ def mcp_handle(message: dict):
396
+ """Answer one JSON-RPC message. Returns the response, or None for notifications."""
397
+ method, msg_id, params = message.get("method"), message.get("id"), message.get("params") or {}
398
+ if msg_id is None: # notifications (e.g. notifications/initialized) get no reply
399
+ return None
400
+ reply = {"jsonrpc": "2.0", "id": msg_id}
401
+ if method == "initialize":
402
+ reply["result"] = {
403
+ "protocolVersion": params.get("protocolVersion") or "2025-06-18",
404
+ "capabilities": {"tools": {}},
405
+ "serverInfo": {"name": "parlova", "version": VERSION},
406
+ "instructions": "Transcribe audio/video files and YouTube links with Parlova.",
407
+ }
408
+ elif method == "ping":
409
+ reply["result"] = {}
410
+ elif method == "tools/list":
411
+ reply["result"] = {"tools": MCP_TOOLS}
412
+ elif method == "tools/call":
413
+ try:
414
+ text = mcp_call(params.get("name", ""), params.get("arguments") or {})
415
+ reply["result"] = {"content": [{"type": "text", "text": text}], "isError": False}
416
+ except (ApiError, KeyError, ValueError) as exc:
417
+ reply["result"] = {"content": [{"type": "text", "text": f"Error: {exc}"}], "isError": True}
418
+ else:
419
+ reply["error"] = {"code": -32601, "message": f"Method not found: {method}"}
420
+ return reply
421
+
422
+
423
+ def cmd_mcp_config(args) -> None:
424
+ """Print a ready-to-paste MCP config for this computer (full paths, so GUI apps can find it).
425
+ It contains no token: the MCP server uses the login saved by `parlova login`."""
426
+ script = str(Path(__file__).resolve())
427
+ python = sys.executable or "python3"
428
+ if args.app == "claude-code":
429
+ print(f'claude mcp add parlova --scope user -- "{python}" "{script}" mcp')
430
+ return
431
+ server = {"command": python, "args": [script, "mcp"]}
432
+ if args.app == "vscode":
433
+ config = {"servers": {"parlova": {"type": "stdio", **server}}}
434
+ else: # claude-desktop, cursor and most other apps use this shape
435
+ config = {"mcpServers": {"parlova": server}}
436
+ print(json.dumps(config, indent=2))
437
+ if not os.environ.get("PARLOVA_TOKEN") and not load_config().get("token"):
438
+ print("\nNote: you're not logged in yet. Run: parlova login", file=sys.stderr)
439
+
440
+
441
+ def cmd_mcp(args) -> None:
442
+ for line in sys.stdin:
443
+ line = line.strip()
444
+ if not line:
445
+ continue
446
+ try:
447
+ message = json.loads(line)
448
+ except ValueError:
449
+ response = {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}}
450
+ else:
451
+ response = mcp_handle(message)
452
+ if response is not None:
453
+ sys.stdout.write(json.dumps(response) + "\n")
454
+ sys.stdout.flush()
455
+
456
+
457
+ # --------------------------------------------------------------------------- #
458
+
459
+ def main(argv=None) -> int:
460
+ parser = argparse.ArgumentParser(prog="parlova", description="Transcribe recordings and YouTube links with Parlova.")
461
+ parser.add_argument("--version", action="version", version=f"parlova {VERSION}")
462
+ sub = parser.add_subparsers(dest="command", required=True)
463
+
464
+ p = sub.add_parser("login", help="sign in through your browser")
465
+ p.add_argument("--token", nargs="?", const="", default=None,
466
+ help="use an access token instead (for servers/CI); leave the value empty to be asked for it")
467
+ p.add_argument("--no-browser", action="store_true", help="print the sign-in link instead of opening a browser")
468
+ p.add_argument("--url", help="your Parlova address, if different")
469
+ p.set_defaults(func=cmd_login)
470
+
471
+ sub.add_parser("logout", help="forget the saved token").set_defaults(func=cmd_logout)
472
+
473
+ p = sub.add_parser("transcribe", help="transcribe files or YouTube links")
474
+ p.add_argument("sources", nargs="+", metavar="FILE_OR_URL")
475
+ p.add_argument("--speakers", action="store_true", help="label who said what (Plus/Pro)")
476
+ p.add_argument("--language", default="auto", help="spoken language code, e.g. en (default: auto)")
477
+ p.add_argument("--format", choices=["text", *FORMATS], default="text", help="output format (default: text)")
478
+ p.add_argument("-o", "--output", help="save to this file instead of printing")
479
+ p.add_argument("--no-wait", action="store_true", help="just start the job and print its id")
480
+ p.set_defaults(func=cmd_transcribe)
481
+
482
+ p = sub.add_parser("list", help="your recent transcriptions")
483
+ p.add_argument("--limit", type=int, default=20)
484
+ p.set_defaults(func=cmd_list)
485
+
486
+ p = sub.add_parser("get", help="print or download a finished transcript")
487
+ p.add_argument("job_id")
488
+ p.add_argument("--format", choices=["text", *FORMATS], default="text")
489
+ p.add_argument("-o", "--output")
490
+ p.set_defaults(func=cmd_get)
491
+
492
+ sub.add_parser("usage", help="minutes used this month").set_defaults(func=cmd_usage)
493
+ p = sub.add_parser("mcp-config", help="print the MCP config to paste into your AI app")
494
+ p.add_argument("--app", choices=["claude-desktop", "claude-code", "cursor", "vscode"], default="claude-desktop")
495
+ p.set_defaults(func=cmd_mcp_config)
496
+ sub.add_parser("mcp", help="run as an MCP server over stdio (for AI apps)").set_defaults(func=cmd_mcp)
497
+
498
+ args = parser.parse_args(argv)
499
+ try:
500
+ args.func(args)
501
+ except ApiError as exc:
502
+ print(f"parlova: {exc}", file=sys.stderr)
503
+ return 1
504
+ except KeyboardInterrupt:
505
+ return 130
506
+ return 0
507
+
508
+
509
+ if __name__ == "__main__":
510
+ sys.exit(main())
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "parlova"
7
+ version = "1.0.0"
8
+ description = "Transcribe audio, video and YouTube from your terminal or from Claude, Cursor and VS Code (MCP server)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "SoleCreations" }]
14
+ keywords = ["mcp", "model-context-protocol", "transcription", "speech-to-text", "youtube", "subtitles", "claude", "cursor", "afrikaans"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "Topic :: Multimedia :: Sound/Audio :: Speech",
20
+ "Operating System :: OS Independent",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.urls]
25
+ Homepage = "https://parlova.co.za"
26
+ Source = "https://github.com/fort652/parlova-mcp"
27
+
28
+ [project.scripts]
29
+ parlova = "parlova:main"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ only-include = ["parlova.py"]
33
+
34
+ [tool.hatch.build.targets.sdist]
35
+ include = ["parlova.py", "README.md", "LICENSE", "pyproject.toml"]