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.
- anyscribecli/__init__.py +3 -0
- anyscribecli/cli/__init__.py +0 -0
- anyscribecli/cli/batch.py +160 -0
- anyscribecli/cli/config_cmd.py +187 -0
- anyscribecli/cli/download.py +160 -0
- anyscribecli/cli/main.py +122 -0
- anyscribecli/cli/onboard.py +289 -0
- anyscribecli/cli/transcribe.py +200 -0
- anyscribecli/config/__init__.py +0 -0
- anyscribecli/config/paths.py +27 -0
- anyscribecli/config/settings.py +86 -0
- anyscribecli/core/__init__.py +0 -0
- anyscribecli/core/audio.py +69 -0
- anyscribecli/core/deps.py +274 -0
- anyscribecli/core/orchestrator.py +98 -0
- anyscribecli/core/updater.py +274 -0
- anyscribecli/downloaders/__init__.py +0 -0
- anyscribecli/downloaders/base.py +32 -0
- anyscribecli/downloaders/instagram.py +169 -0
- anyscribecli/downloaders/registry.py +35 -0
- anyscribecli/downloaders/youtube.py +79 -0
- anyscribecli/providers/__init__.py +36 -0
- anyscribecli/providers/base.py +45 -0
- anyscribecli/providers/elevenlabs.py +146 -0
- anyscribecli/providers/local.py +102 -0
- anyscribecli/providers/openai.py +133 -0
- anyscribecli/providers/openrouter.py +127 -0
- anyscribecli/providers/sargam.py +146 -0
- anyscribecli/vault/__init__.py +0 -0
- anyscribecli/vault/index.py +106 -0
- anyscribecli/vault/scaffold.py +69 -0
- anyscribecli/vault/writer.py +125 -0
- anyscribecli-0.3.1.dist-info/METADATA +269 -0
- anyscribecli-0.3.1.dist-info/RECORD +37 -0
- anyscribecli-0.3.1.dist-info/WHEEL +4 -0
- anyscribecli-0.3.1.dist-info/entry_points.txt +2 -0
- anyscribecli-0.3.1.dist-info/licenses/LICENSE +21 -0
anyscribecli/__init__.py
ADDED
|
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
|
+
}
|
anyscribecli/cli/main.py
ADDED
|
@@ -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)
|