anyscribecli 0.3.1__py3-none-any.whl

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
+ """anyscribecli — download, transcribe, and convert video/audio to structured markdown."""
2
+
3
+ __version__ = "0.3.1"
File without changes
@@ -0,0 +1,160 @@
1
+ """Batch processing command — transcribe multiple URLs from a file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, MofNCompleteColumn
12
+ from rich.table import Table
13
+
14
+ from anyscribecli.config.settings import load_config, load_env
15
+
16
+ console = Console()
17
+ err_console = Console(stderr=True)
18
+
19
+
20
+ def batch(
21
+ file: Path = typer.Argument(..., help="File containing URLs (one per line)."),
22
+ provider: str | None = typer.Option(None, "--provider", "-p", help="Override provider."),
23
+ language: str | None = typer.Option(None, "--language", "-l", help="Override language."),
24
+ output_json: bool = typer.Option(False, "--json", "-j", help="Output results as JSON."),
25
+ keep_media: bool = typer.Option(False, "--keep-media", help="Keep audio files."),
26
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress progress."),
27
+ stop_on_error: bool = typer.Option(False, "--stop-on-error", help="Stop at first failure."),
28
+ ) -> None:
29
+ """[bold magenta]Batch transcribe[/bold magenta] URLs from a file.
30
+
31
+ Reads a file with one URL per line (blank lines and #comments are skipped).
32
+ Processes each URL sequentially and reports results.
33
+ """
34
+ if not file.exists():
35
+ err_console.print(f"[red]File not found:[/red] {file}")
36
+ raise typer.Exit(code=1)
37
+
38
+ # Parse URLs from file
39
+ urls: list[str] = []
40
+ for line in file.read_text().splitlines():
41
+ line = line.strip()
42
+ if line and not line.startswith("#"):
43
+ urls.append(line)
44
+
45
+ if not urls:
46
+ err_console.print("[yellow]No URLs found in file.[/yellow]")
47
+ raise typer.Exit()
48
+
49
+ load_env()
50
+ settings = load_config()
51
+ if provider:
52
+ settings.provider = provider
53
+ if language:
54
+ settings.language = language
55
+ if keep_media:
56
+ settings.keep_media = True
57
+
58
+ results: list[dict] = []
59
+ succeeded = 0
60
+ failed = 0
61
+
62
+ if quiet or output_json:
63
+ # No progress display
64
+ for url in urls:
65
+ succeeded, failed = _process_url(
66
+ url, settings, results, succeeded, failed, quiet=True
67
+ )
68
+ if failed and stop_on_error:
69
+ break
70
+ else:
71
+ # Rich progress bar
72
+ with Progress(
73
+ SpinnerColumn(),
74
+ TextColumn("[bold]{task.description}"),
75
+ BarColumn(),
76
+ MofNCompleteColumn(),
77
+ console=err_console,
78
+ ) as progress:
79
+ task = progress.add_task("Transcribing", total=len(urls))
80
+
81
+ for url in urls:
82
+ progress.update(task, description=f"[bold]{_shorten_url(url)}")
83
+ succeeded, failed = _process_url(
84
+ url, settings, results, succeeded, failed, quiet=True
85
+ )
86
+ progress.advance(task)
87
+ if failed and stop_on_error:
88
+ err_console.print("[red]Stopping on error (--stop-on-error).[/red]")
89
+ break
90
+
91
+ # Summary
92
+ if output_json:
93
+ json.dump({
94
+ "total": len(urls),
95
+ "succeeded": succeeded,
96
+ "failed": failed,
97
+ "results": results,
98
+ }, sys.stdout, indent=2)
99
+ sys.stdout.write("\n")
100
+ else:
101
+ console.print(f"\n[bold]Batch complete:[/bold] {succeeded} succeeded, {failed} failed, {len(urls)} total")
102
+
103
+ if results:
104
+ table = Table(title="Results")
105
+ table.add_column("#", style="dim")
106
+ table.add_column("Status")
107
+ table.add_column("Title / Error")
108
+
109
+ for i, r in enumerate(results, 1):
110
+ if r["success"]:
111
+ table.add_row(str(i), "[green]OK[/green]", r["title"])
112
+ else:
113
+ table.add_row(str(i), "[red]FAIL[/red]", r["error"][:80])
114
+
115
+ console.print(table)
116
+
117
+ if failed > 0:
118
+ raise typer.Exit(code=1)
119
+
120
+
121
+ def _shorten_url(url: str, max_len: int = 50) -> str:
122
+ """Shorten a URL for display in progress bar."""
123
+ url = url.replace("https://", "").replace("http://", "").replace("www.", "")
124
+ if len(url) > max_len:
125
+ return url[: max_len - 3] + "..."
126
+ return url
127
+
128
+
129
+ def _process_url(
130
+ url: str,
131
+ settings,
132
+ results: list[dict],
133
+ succeeded: int,
134
+ failed: int,
135
+ quiet: bool,
136
+ ) -> tuple[int, int]:
137
+ """Process a single URL, append to results. Returns (succeeded, failed)."""
138
+ from anyscribecli.core.orchestrator import process
139
+
140
+ try:
141
+ result = process(url, settings, quiet=quiet)
142
+ succeeded += 1
143
+ results.append({
144
+ "success": True,
145
+ "url": url,
146
+ "file": str(result.file_path),
147
+ "title": result.title,
148
+ "platform": result.platform,
149
+ "duration": result.duration,
150
+ "language": result.language,
151
+ "word_count": result.word_count,
152
+ })
153
+ except Exception as e:
154
+ failed += 1
155
+ results.append({
156
+ "success": False,
157
+ "url": url,
158
+ "error": str(e),
159
+ })
160
+ return succeeded, failed
@@ -0,0 +1,187 @@
1
+ """Config and providers CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from typing import Optional
9
+
10
+ import typer
11
+ import yaml
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ from anyscribecli.config.paths import CONFIG_FILE
16
+ from anyscribecli.config.settings import load_config, save_config, load_env
17
+ from anyscribecli.providers import list_providers, get_provider
18
+
19
+ console = Console()
20
+ err_console = Console(stderr=True)
21
+
22
+ # ── Config subcommands ────────────────────────────────────────
23
+
24
+ config_app = typer.Typer(
25
+ name="config",
26
+ help="View and change ascli settings.",
27
+ rich_markup_mode="rich",
28
+ no_args_is_help=True,
29
+ )
30
+
31
+
32
+ @config_app.command("show")
33
+ def config_show(
34
+ output_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON."),
35
+ ) -> None:
36
+ """[bold]Show[/bold] current configuration."""
37
+ settings = load_config()
38
+ data = settings.to_dict()
39
+
40
+ if output_json:
41
+ json.dump(data, sys.stdout, indent=2)
42
+ sys.stdout.write("\n")
43
+ else:
44
+ console.print(f"[dim]Config file: {CONFIG_FILE}[/dim]\n")
45
+ console.print(yaml.dump(data, default_flow_style=False, sort_keys=False).strip())
46
+
47
+
48
+ @config_app.command("set")
49
+ def config_set(
50
+ key: str = typer.Argument(..., help="Setting key (e.g., 'provider', 'language', 'instagram.username')."),
51
+ value: str = typer.Argument(..., help="New value."),
52
+ ) -> None:
53
+ """[bold]Change[/bold] a configuration setting.
54
+
55
+ Use dot-notation for nested keys: `ascli config set instagram.username myuser`
56
+ """
57
+ settings = load_config()
58
+ data = settings.to_dict()
59
+
60
+ # Handle dot-notation (e.g., instagram.username)
61
+ keys = key.split(".")
62
+ target = data
63
+ for k in keys[:-1]:
64
+ if k not in target or not isinstance(target[k], dict):
65
+ err_console.print(f"[red]Invalid key: {key}[/red]")
66
+ raise typer.Exit(code=1)
67
+ target = target[k]
68
+
69
+ final_key = keys[-1]
70
+ if final_key not in target:
71
+ err_console.print(f"[red]Unknown key: {key}[/red]")
72
+ err_console.print(f"Available: {', '.join(_flat_keys(settings.to_dict()))}")
73
+ raise typer.Exit(code=1)
74
+
75
+ # Type coercion
76
+ old_value = target[final_key]
77
+ if isinstance(old_value, bool):
78
+ value_typed = value.lower() in ("true", "1", "yes")
79
+ elif isinstance(old_value, int):
80
+ value_typed = int(value)
81
+ else:
82
+ value_typed = value
83
+
84
+ target[final_key] = value_typed
85
+
86
+ from anyscribecli.config.settings import Settings
87
+ new_settings = Settings.from_dict(data)
88
+ save_config(new_settings)
89
+ console.print(f"[green]Set[/green] {key} = {value_typed}")
90
+
91
+
92
+ @config_app.command("path")
93
+ def config_path() -> None:
94
+ """[bold]Print[/bold] the config file location."""
95
+ console.print(str(CONFIG_FILE))
96
+
97
+
98
+ def _flat_keys(d: dict, prefix: str = "") -> list[str]:
99
+ """Flatten a dict into dot-notation keys."""
100
+ keys = []
101
+ for k, v in d.items():
102
+ full = f"{prefix}{k}" if not prefix else f"{prefix}.{k}"
103
+ if isinstance(v, dict):
104
+ keys.extend(_flat_keys(v, full))
105
+ else:
106
+ keys.append(full)
107
+ return keys
108
+
109
+
110
+ # ── Providers subcommands ─────────────────────────────────────
111
+
112
+ providers_app = typer.Typer(
113
+ name="providers",
114
+ help="Manage transcription providers.",
115
+ rich_markup_mode="rich",
116
+ no_args_is_help=True,
117
+ )
118
+
119
+
120
+ @providers_app.command("list")
121
+ def providers_list(
122
+ output_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON."),
123
+ ) -> None:
124
+ """[bold]List[/bold] available transcription providers."""
125
+ settings = load_config()
126
+ active = settings.provider
127
+ providers = list_providers()
128
+
129
+ if output_json:
130
+ result = [
131
+ {"name": p, "active": p == active}
132
+ for p in providers
133
+ ]
134
+ json.dump(result, sys.stdout, indent=2)
135
+ sys.stdout.write("\n")
136
+ else:
137
+ table = Table(title="Transcription Providers")
138
+ table.add_column("Provider", style="bold")
139
+ table.add_column("Status")
140
+ table.add_column("Active")
141
+
142
+ for p in providers:
143
+ is_active = "[green]Active[/green]" if p == active else ""
144
+ table.add_row(p, "Available", is_active)
145
+
146
+ console.print(table)
147
+ console.print("\n[dim]Change with: ascli config set provider <name>[/dim]")
148
+
149
+
150
+ @providers_app.command("test")
151
+ def providers_test(
152
+ name: Optional[str] = typer.Argument(None, help="Provider to test (default: active provider)."),
153
+ ) -> None:
154
+ """[bold]Test[/bold] a provider's API key and connectivity."""
155
+ load_env()
156
+ settings = load_config()
157
+ provider_name = name or settings.provider
158
+
159
+ console.print(f"Testing provider: [bold]{provider_name}[/bold]")
160
+
161
+ try:
162
+ provider = get_provider(provider_name)
163
+ except ValueError as e:
164
+ err_console.print(f"[red]{e}[/red]")
165
+ raise typer.Exit(code=1)
166
+
167
+ # Check if API key is set
168
+ key_map = {
169
+ "openai": "OPENAI_API_KEY",
170
+ "openrouter": "OPENROUTER_API_KEY",
171
+ "elevenlabs": "ELEVENLABS_API_KEY",
172
+ "sargam": "SARGAM_API_KEY",
173
+ }
174
+ env_var = key_map.get(provider_name)
175
+ if env_var:
176
+ if os.environ.get(env_var):
177
+ console.print(f" API key ({env_var}): [green]Set[/green]")
178
+ else:
179
+ console.print(f" API key ({env_var}): [red]Not set[/red]")
180
+ console.print(" Add it to ~/.anyscribecli/.env")
181
+ raise typer.Exit(code=1)
182
+
183
+ if provider_name == "local":
184
+ console.print(" [green]Local provider — no API key needed.[/green]")
185
+
186
+ console.print(f" Provider class: {provider.__class__.__name__}")
187
+ console.print(" [green]Provider loaded successfully.[/green]")
@@ -0,0 +1,160 @@
1
+ """Download command — download video only, no transcription."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ import typer
14
+ from rich.console import Console
15
+
16
+ from anyscribecli.config.paths import TMP_DIR, VIDEO_DIR, AUDIO_DIR
17
+ from anyscribecli.config.settings import load_env
18
+
19
+ console = Console()
20
+ err_console = Console(stderr=True)
21
+
22
+
23
+ def download(
24
+ url: Optional[str] = typer.Argument(None, help="YouTube or Instagram URL to download."),
25
+ video: bool = typer.Option(True, "--video/--audio-only", help="Download video (default) or audio only."),
26
+ output_json: bool = typer.Option(False, "--json", "-j", help="Output result as JSON."),
27
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress progress output."),
28
+ clipboard: bool = typer.Option(False, "--clipboard", "-c", help="Read URL from clipboard."),
29
+ ) -> None:
30
+ """[bold green]Download[/bold green] video or audio from a URL — no transcription.
31
+
32
+ Saves to ~/.anyscribecli/media/video/ or ~/.anyscribecli/media/audio/.
33
+
34
+ [dim]Tip: Wrap URLs in quotes to avoid shell issues.[/dim]
35
+ """
36
+ from anyscribecli.cli.transcribe import _read_clipboard, _validate_url
37
+ from anyscribecli.downloaders.registry import get_downloader, detect_platform
38
+
39
+ # Resolve URL
40
+ if clipboard:
41
+ url = _read_clipboard()
42
+ if not url:
43
+ err_console.print("[red]Error:[/red] No URL found in clipboard.")
44
+ raise typer.Exit(code=1)
45
+
46
+ if not url:
47
+ console.print(" Paste a YouTube or Instagram URL:")
48
+ url = typer.prompt(" URL")
49
+
50
+ if not url:
51
+ err_console.print("[red]Error:[/red] No URL provided.")
52
+ raise typer.Exit(code=1)
53
+
54
+ url = _validate_url(url)
55
+ load_env()
56
+
57
+ from datetime import date
58
+ from anyscribecli.vault.writer import slugify
59
+
60
+ TMP_DIR.mkdir(parents=True, exist_ok=True)
61
+ tmp_dir = Path(tempfile.mkdtemp(dir=TMP_DIR))
62
+
63
+ try:
64
+ if not quiet:
65
+ err_console.print("[bold blue]Downloading...[/bold blue]")
66
+
67
+ downloader = get_downloader(url)
68
+ platform = detect_platform(url)
69
+
70
+ if video:
71
+ # Download full video
72
+ result = _download_video(url, platform, tmp_dir, quiet)
73
+ else:
74
+ # Download audio only (same as transcribe pipeline)
75
+ dl_result = downloader.download(url, tmp_dir)
76
+ today = date.today().isoformat()
77
+ slug = slugify(dl_result.title) or "untitled"
78
+ dest_dir = AUDIO_DIR / platform / today
79
+ dest_dir.mkdir(parents=True, exist_ok=True)
80
+ dest = dest_dir / f"{slug}{dl_result.audio_path.suffix}"
81
+ shutil.copy2(dl_result.audio_path, dest)
82
+ result = {
83
+ "file": str(dest),
84
+ "title": dl_result.title,
85
+ "platform": platform,
86
+ "type": "audio",
87
+ "duration": dl_result.duration,
88
+ }
89
+
90
+ if output_json:
91
+ json.dump({"success": True, **result}, sys.stdout, indent=2)
92
+ sys.stdout.write("\n")
93
+ else:
94
+ console.print(f"\n[green]Downloaded:[/green] {result['file']}")
95
+ console.print(f" Title: {result['title']}")
96
+ console.print(f" Type: {result['type']}")
97
+
98
+ except Exception as e:
99
+ if output_json:
100
+ json.dump({"success": False, "error": str(e)}, sys.stdout, indent=2)
101
+ sys.stdout.write("\n")
102
+ else:
103
+ err_console.print(f"[red]Error:[/red] {e}")
104
+ raise typer.Exit(code=1)
105
+ finally:
106
+ if tmp_dir.exists():
107
+ shutil.rmtree(tmp_dir, ignore_errors=True)
108
+
109
+
110
+ def _download_video(url: str, platform: str, tmp_dir: Path, quiet: bool) -> dict:
111
+ """Download full video using yt-dlp (works for both YouTube and Instagram)."""
112
+ from datetime import date
113
+ from anyscribecli.vault.writer import slugify
114
+
115
+ # yt-dlp can handle both YouTube and Instagram video URLs
116
+ output_template = str(tmp_dir / "%(title).80s.%(ext)s")
117
+ cmd = [
118
+ "yt-dlp",
119
+ "--format", "bestvideo[height<=1080][ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best",
120
+ "--merge-output-format", "mp4",
121
+ "--output", output_template,
122
+ "--no-playlist",
123
+ "--print-json",
124
+ ]
125
+ if quiet:
126
+ cmd.append("--quiet")
127
+ cmd.append(url)
128
+
129
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
130
+ if result.returncode != 0:
131
+ raise RuntimeError(f"Download failed: {result.stderr.strip()[:300]}")
132
+
133
+ import json as _json
134
+ metadata = _json.loads(result.stdout.split("\n")[0])
135
+ title = metadata.get("title", "untitled")
136
+
137
+ # Find the downloaded video
138
+ video_files = list(tmp_dir.glob("*.mp4")) + list(tmp_dir.glob("*.mkv")) + list(tmp_dir.glob("*.webm"))
139
+ if not video_files:
140
+ raise RuntimeError("Download completed but no video file found.")
141
+ video_path = video_files[0]
142
+
143
+ # Move to media/video/<platform>/YYYY-MM-DD/<slug>.mp4
144
+ today = date.today().isoformat()
145
+ slug = slugify(title) or "untitled"
146
+ dest_dir = VIDEO_DIR / platform / today
147
+ dest_dir.mkdir(parents=True, exist_ok=True)
148
+ dest = dest_dir / f"{slug}{video_path.suffix}"
149
+ shutil.move(str(video_path), str(dest))
150
+
151
+ if not quiet:
152
+ err_console.print(f" [green]Saved:[/green] {title}")
153
+
154
+ return {
155
+ "file": str(dest),
156
+ "title": title,
157
+ "platform": platform,
158
+ "type": "video",
159
+ "duration": metadata.get("duration"),
160
+ }
@@ -0,0 +1,122 @@
1
+ """ascli — main CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from anyscribecli import __version__
11
+
12
+ app = typer.Typer(
13
+ name="ascli",
14
+ help="Download, transcribe, and convert video/audio to structured markdown.",
15
+ rich_markup_mode="rich",
16
+ no_args_is_help=True,
17
+ )
18
+
19
+ console = Console()
20
+ err_console = Console(stderr=True)
21
+
22
+
23
+ def version_callback(value: bool) -> None:
24
+ if value:
25
+ console.print(f"ascli v{__version__}")
26
+ raise typer.Exit()
27
+
28
+
29
+ @app.callback()
30
+ def main(
31
+ version: Optional[bool] = typer.Option(
32
+ None,
33
+ "--version",
34
+ "-v",
35
+ help="Show version and exit.",
36
+ callback=version_callback,
37
+ is_eager=True,
38
+ ),
39
+ ) -> None:
40
+ """[bold]ascli[/bold] — download, transcribe, and convert video/audio to structured markdown."""
41
+
42
+
43
+ # Register commands
44
+ from anyscribecli.cli.onboard import onboard # noqa: E402
45
+ from anyscribecli.cli.transcribe import transcribe # noqa: E402
46
+ from anyscribecli.cli.config_cmd import config_app, providers_app # noqa: E402
47
+ from anyscribecli.cli.batch import batch # noqa: E402
48
+ from anyscribecli.cli.download import download # noqa: E402
49
+
50
+ app.command()(onboard)
51
+ app.command()(transcribe)
52
+ app.command()(batch)
53
+ app.command()(download)
54
+ app.add_typer(config_app, name="config")
55
+ app.add_typer(providers_app, name="providers")
56
+
57
+
58
+ @app.command()
59
+ def update(
60
+ force: bool = typer.Option(False, "--force", "-f", help="Force update even with local changes."),
61
+ check: bool = typer.Option(False, "--check", "-c", help="Only check for updates, don't install."),
62
+ ) -> None:
63
+ """[bold yellow]Update[/bold yellow] ascli to the latest version.
64
+
65
+ Pulls the latest changes from git and reinstalls the package.
66
+ """
67
+ from anyscribecli.core.updater import check_for_updates, update as do_update
68
+
69
+ if check:
70
+ check_for_updates(quiet=False)
71
+ else:
72
+ success = do_update(force=force)
73
+ if not success:
74
+ raise typer.Exit(code=1)
75
+
76
+
77
+ @app.command()
78
+ def doctor() -> None:
79
+ """[bold]Check[/bold] system health — dependencies, config, and workspace.
80
+
81
+ Runs all diagnostic checks and reports status.
82
+ """
83
+ from anyscribecli.core.deps import check_dependencies, print_dependency_status
84
+ from anyscribecli.config.paths import APP_HOME, CONFIG_FILE, ENV_FILE, WORKSPACE_DIR
85
+ from anyscribecli.core.updater import get_install_path, check_for_updates
86
+
87
+ console.print("[bold]ascli doctor[/bold]\n")
88
+
89
+ # Dependencies
90
+ console.print("[bold]1. System Dependencies[/bold]\n")
91
+ results = check_dependencies()
92
+ print_dependency_status(results)
93
+
94
+ # Config
95
+ console.print("\n[bold]2. Configuration[/bold]\n")
96
+ checks = [
97
+ ("App directory", APP_HOME.exists()),
98
+ ("Config file", CONFIG_FILE.exists()),
99
+ ("API keys file", ENV_FILE.exists()),
100
+ ("Workspace vault", WORKSPACE_DIR.exists()),
101
+ ("Workspace index", (WORKSPACE_DIR / "_index.md").exists()),
102
+ ]
103
+ for name, ok in checks:
104
+ status = "[green]OK[/green]" if ok else "[red]Missing[/red]"
105
+ console.print(f" {name}: {status}")
106
+
107
+ if not CONFIG_FILE.exists():
108
+ console.print("\n [yellow]Run [bold]ascli onboard[/bold] to set up.[/yellow]")
109
+
110
+ # Install info
111
+ console.print("\n[bold]3. Installation[/bold]\n")
112
+ console.print(f" Version: v{__version__}")
113
+ repo = get_install_path()
114
+ if repo:
115
+ console.print(" Install type: git (editable)")
116
+ console.print(f" Repo path: {repo}")
117
+ else:
118
+ console.print(" Install type: pip package")
119
+
120
+ # Updates
121
+ console.print()
122
+ check_for_updates(quiet=True)