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.
- parlova-1.0.0/.gitignore +3 -0
- parlova-1.0.0/LICENSE +21 -0
- parlova-1.0.0/PKG-INFO +119 -0
- parlova-1.0.0/README.md +101 -0
- parlova-1.0.0/parlova.py +510 -0
- parlova-1.0.0/pyproject.toml +35 -0
parlova-1.0.0/.gitignore
ADDED
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
|
parlova-1.0.0/README.md
ADDED
|
@@ -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
|
parlova-1.0.0/parlova.py
ADDED
|
@@ -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"]
|