openbbq 0.0.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.
- openbbq/__init__.py +1 -0
- openbbq/cli/__init__.py +48 -0
- openbbq/cli/app.py +47 -0
- openbbq/cli/commands/__init__.py +0 -0
- openbbq/cli/commands/auth.py +97 -0
- openbbq/cli/commands/burn.py +250 -0
- openbbq/cli/commands/doctor.py +54 -0
- openbbq/cli/commands/export.py +154 -0
- openbbq/cli/commands/extract_audio.py +71 -0
- openbbq/cli/commands/fetch.py +327 -0
- openbbq/cli/commands/glossary.py +213 -0
- openbbq/cli/commands/init.py +57 -0
- openbbq/cli/commands/models.py +129 -0
- openbbq/cli/commands/segment.py +136 -0
- openbbq/cli/commands/status.py +90 -0
- openbbq/cli/commands/transcribe.py +224 -0
- openbbq/cli/commands/translate.py +169 -0
- openbbq/cli/output.py +63 -0
- openbbq/cli/results.py +26 -0
- openbbq/core/__init__.py +0 -0
- openbbq/core/asr/__init__.py +28 -0
- openbbq/core/asr/base.py +68 -0
- openbbq/core/asr/whispercpp.py +338 -0
- openbbq/core/auth/__init__.py +5 -0
- openbbq/core/auth/browser.py +244 -0
- openbbq/core/auth/cookies.py +55 -0
- openbbq/core/auth/sites.py +175 -0
- openbbq/core/auth/store.py +160 -0
- openbbq/core/doctor.py +153 -0
- openbbq/core/export.py +441 -0
- openbbq/core/fetch.py +334 -0
- openbbq/core/glossary.py +245 -0
- openbbq/core/media.py +310 -0
- openbbq/core/models.py +43 -0
- openbbq/core/segment.py +375 -0
- openbbq/core/translate.py +169 -0
- openbbq/core/workspace.py +299 -0
- openbbq/errors.py +25 -0
- openbbq/schemas/__init__.py +37 -0
- openbbq/schemas/common.py +44 -0
- openbbq/schemas/cues.py +53 -0
- openbbq/schemas/glossary.py +34 -0
- openbbq/schemas/manifest.py +42 -0
- openbbq/schemas/transcript.py +40 -0
- openbbq/schemas/translation.py +43 -0
- openbbq-0.0.1.dist-info/METADATA +119 -0
- openbbq-0.0.1.dist-info/RECORD +50 -0
- openbbq-0.0.1.dist-info/WHEEL +4 -0
- openbbq-0.0.1.dist-info/entry_points.txt +3 -0
- openbbq-0.0.1.dist-info/licenses/LICENSE +201 -0
openbbq/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.1"
|
openbbq/cli/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from typer._click import ClickException # typer >=0.26 bundles click privately
|
|
7
|
+
from typer._click.exceptions import Abort
|
|
8
|
+
from typer.main import get_command
|
|
9
|
+
|
|
10
|
+
from ..errors import OpenBBQError
|
|
11
|
+
from .app import app
|
|
12
|
+
from .output import Output
|
|
13
|
+
|
|
14
|
+
__all__ = ["app", "main"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main() -> None:
|
|
18
|
+
"""Console entry point, split by audience.
|
|
19
|
+
|
|
20
|
+
Human (TTY): typer renders everything natively — help, Rich usage panels,
|
|
21
|
+
completion, exit codes — and we only translate ``OpenBBQError``.
|
|
22
|
+
Machine (``--json`` / non-TTY): a single chokepoint emits compact JSON for
|
|
23
|
+
success, domain errors (exit 1) and usage errors (exit 2) alike, so
|
|
24
|
+
``--json`` is honored uniformly.
|
|
25
|
+
"""
|
|
26
|
+
if "--json" not in sys.argv and sys.stdout.isatty():
|
|
27
|
+
try:
|
|
28
|
+
app()
|
|
29
|
+
except OpenBBQError as err:
|
|
30
|
+
Output(json_mode=False).error(err)
|
|
31
|
+
raise SystemExit(1) from err
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
output = Output(json_mode=True)
|
|
35
|
+
command = get_command(app)
|
|
36
|
+
try:
|
|
37
|
+
rv = command(args=sys.argv[1:], prog_name="openbbq", standalone_mode=False)
|
|
38
|
+
except OpenBBQError as err:
|
|
39
|
+
output.error(err)
|
|
40
|
+
raise SystemExit(1) from err
|
|
41
|
+
except typer.Exit as err:
|
|
42
|
+
raise SystemExit(err.exit_code) from err
|
|
43
|
+
except Abort as err:
|
|
44
|
+
raise SystemExit(130) from err
|
|
45
|
+
except ClickException as err: # missing/bad arg, unknown command, no command
|
|
46
|
+
output.usage_error(err)
|
|
47
|
+
raise SystemExit(err.exit_code) from err
|
|
48
|
+
raise SystemExit(rv if isinstance(rv, int) else 0)
|
openbbq/cli/app.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from .commands.auth import app as auth_app
|
|
8
|
+
from .commands.burn import burn
|
|
9
|
+
from .commands.doctor import doctor
|
|
10
|
+
from .commands.export import export
|
|
11
|
+
from .commands.extract_audio import extract_audio
|
|
12
|
+
from .commands.fetch import fetch
|
|
13
|
+
from .commands.glossary import app as glossary_app
|
|
14
|
+
from .commands.init import init
|
|
15
|
+
from .commands.models import app as models_app
|
|
16
|
+
from .commands.segment import segment
|
|
17
|
+
from .commands.status import status
|
|
18
|
+
from .commands.transcribe import transcribe
|
|
19
|
+
from .commands.translate import app as translate_app
|
|
20
|
+
from .output import Output
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(no_args_is_help=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.callback()
|
|
26
|
+
def _root(
|
|
27
|
+
ctx: typer.Context,
|
|
28
|
+
json: Annotated[
|
|
29
|
+
bool, typer.Option("--json", help="Emit machine-readable JSON")
|
|
30
|
+
] = False,
|
|
31
|
+
) -> None:
|
|
32
|
+
ctx.obj = Output.detect(json)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
app.command()(doctor)
|
|
36
|
+
app.command()(init)
|
|
37
|
+
app.command()(status)
|
|
38
|
+
app.command()(fetch)
|
|
39
|
+
app.command(name="extract-audio")(extract_audio)
|
|
40
|
+
app.command()(transcribe)
|
|
41
|
+
app.command()(segment)
|
|
42
|
+
app.add_typer(translate_app, name="translate")
|
|
43
|
+
app.command()(export)
|
|
44
|
+
app.command()(burn)
|
|
45
|
+
app.add_typer(models_app, name="models")
|
|
46
|
+
app.add_typer(glossary_app, name="glossary")
|
|
47
|
+
app.add_typer(auth_app, name="auth")
|
|
File without changes
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from ...core.auth import browser
|
|
9
|
+
from ...core.auth import sites
|
|
10
|
+
from ...core.auth import store as auth_store
|
|
11
|
+
from ..output import Output
|
|
12
|
+
from ..results import Result
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(no_args_is_help=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthStatusResult(Result):
|
|
18
|
+
site: str
|
|
19
|
+
configured: bool
|
|
20
|
+
cookie_count: int = 0
|
|
21
|
+
auth_cookie_count: int = 0
|
|
22
|
+
expires_at: datetime | None = None
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def of(cls, status: auth_store.AuthStatus) -> AuthStatusResult:
|
|
26
|
+
return cls(
|
|
27
|
+
site=status.site,
|
|
28
|
+
configured=status.configured,
|
|
29
|
+
cookie_count=status.cookie_count,
|
|
30
|
+
auth_cookie_count=status.auth_cookie_count,
|
|
31
|
+
expires_at=status.expires_at,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def render(self) -> str:
|
|
35
|
+
if not self.configured:
|
|
36
|
+
return f"{self.site}: not configured"
|
|
37
|
+
expiry = f"\n expires: {self.expires_at.isoformat()}" if self.expires_at else ""
|
|
38
|
+
return (
|
|
39
|
+
f"[green]✓[/] {self.site} auth configured\n"
|
|
40
|
+
f" cookies: {self.cookie_count} ({self.auth_cookie_count} auth){expiry}"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AuthClearResult(Result):
|
|
45
|
+
site: str
|
|
46
|
+
|
|
47
|
+
def render(self) -> str:
|
|
48
|
+
return f"[green]✓[/] cleared auth: {self.site}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _stderr(message: str) -> None:
|
|
52
|
+
typer.echo(message, err=True)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command(name="browser-login")
|
|
56
|
+
def browser_login(
|
|
57
|
+
ctx: typer.Context,
|
|
58
|
+
site: Annotated[str, typer.Argument(help="site key, currently: youtube")],
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Open a browser login window and save site cookies for later use."""
|
|
61
|
+
output: Output = ctx.obj
|
|
62
|
+
policy = sites.require_policy(site)
|
|
63
|
+
|
|
64
|
+
def wait_for_user() -> None:
|
|
65
|
+
_stderr("Complete login in the opened browser window.")
|
|
66
|
+
_stderr("Then close that browser window, return here, and press Enter.")
|
|
67
|
+
input()
|
|
68
|
+
|
|
69
|
+
raw = browser.browser_login(
|
|
70
|
+
policy.key,
|
|
71
|
+
wait_for_user=wait_for_user,
|
|
72
|
+
on_message=_stderr,
|
|
73
|
+
)
|
|
74
|
+
status = auth_store.save_cookies(policy.key, raw)
|
|
75
|
+
output.emit(AuthStatusResult.of(status))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command()
|
|
79
|
+
def status(
|
|
80
|
+
ctx: typer.Context,
|
|
81
|
+
site: Annotated[str, typer.Argument(help="site key, currently: youtube")],
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Show whether a site app session is configured."""
|
|
84
|
+
output: Output = ctx.obj
|
|
85
|
+
output.emit(AuthStatusResult.of(auth_store.status(site)))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command()
|
|
89
|
+
def clear(
|
|
90
|
+
ctx: typer.Context,
|
|
91
|
+
site: Annotated[str, typer.Argument(help="site key, currently: youtube")],
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Remove saved cookies for a site app session."""
|
|
94
|
+
output: Output = ctx.obj
|
|
95
|
+
policy = sites.require_policy(site)
|
|
96
|
+
auth_store.clear(policy.key)
|
|
97
|
+
output.emit(AuthClearResult(site=policy.key))
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Group, RenderableType
|
|
10
|
+
from rich.progress import (
|
|
11
|
+
BarColumn,
|
|
12
|
+
Progress as RichProgress,
|
|
13
|
+
TextColumn,
|
|
14
|
+
TimeElapsedColumn,
|
|
15
|
+
TimeRemainingColumn,
|
|
16
|
+
)
|
|
17
|
+
from rich.style import Style
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
|
|
21
|
+
from ...core import media
|
|
22
|
+
from ...core import workspace as ws
|
|
23
|
+
from ...errors import OpenBBQError
|
|
24
|
+
from ...schemas import Manifest, Progress, Stage, StageState, StageStatus
|
|
25
|
+
from ..output import Output
|
|
26
|
+
from ..results import Result
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BurnResult(Result):
|
|
30
|
+
workspace: str
|
|
31
|
+
artifact: str
|
|
32
|
+
subtitle: str
|
|
33
|
+
duration_s: float | None
|
|
34
|
+
elapsed_s: float
|
|
35
|
+
ffmpeg: str
|
|
36
|
+
|
|
37
|
+
def _artifact_text(self, artifact: str) -> Text:
|
|
38
|
+
path = Path(artifact)
|
|
39
|
+
resolved = path if path.is_absolute() else Path(self.workspace) / path
|
|
40
|
+
return Text(
|
|
41
|
+
artifact, style=Style(color="cyan", link=resolved.resolve().as_uri())
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def _workspace_text(self) -> Text:
|
|
45
|
+
uri = Path(self.workspace).resolve().as_uri()
|
|
46
|
+
return Text(self.workspace, style=Style(color="cyan", link=uri))
|
|
47
|
+
|
|
48
|
+
def render(self) -> RenderableType:
|
|
49
|
+
table = Table.grid(padding=(0, 2))
|
|
50
|
+
table.add_column(justify="right", style="dim", no_wrap=True)
|
|
51
|
+
table.add_column(ratio=1)
|
|
52
|
+
table.add_row("workspace", self._workspace_text())
|
|
53
|
+
table.add_row("file", self._artifact_text(self.artifact))
|
|
54
|
+
table.add_row("subtitle", self._artifact_text(self.subtitle))
|
|
55
|
+
if self.duration_s is not None:
|
|
56
|
+
table.add_row("length", Text(_clock(self.duration_s)))
|
|
57
|
+
table.add_row("elapsed", Text(f"{self.elapsed_s:.1f}s"))
|
|
58
|
+
return Group(Text.assemble(("✓", "green"), (" video burned", "bold")), table)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _now() -> datetime:
|
|
62
|
+
return datetime.now(timezone.utc)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _clock(seconds: float) -> str:
|
|
66
|
+
total = int(round(seconds))
|
|
67
|
+
h, rem = divmod(total, 3600)
|
|
68
|
+
m, s = divmod(rem, 60)
|
|
69
|
+
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _relative_to_workspace(path: Path, workspace: Path) -> str:
|
|
73
|
+
try:
|
|
74
|
+
return str(path.relative_to(workspace))
|
|
75
|
+
except ValueError:
|
|
76
|
+
return str(path)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _resolve_path(value: str, workspace: Path) -> Path:
|
|
80
|
+
path = Path(value).expanduser()
|
|
81
|
+
return path if path.is_absolute() else workspace / path
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _resolve_subtitle(
|
|
85
|
+
workspace: Path, manifest: Manifest, subtitle: str | None
|
|
86
|
+
) -> Path:
|
|
87
|
+
if subtitle is not None:
|
|
88
|
+
path = _resolve_path(subtitle, workspace)
|
|
89
|
+
if not path.exists():
|
|
90
|
+
raise OpenBBQError(
|
|
91
|
+
"missing_input",
|
|
92
|
+
subtitle=subtitle,
|
|
93
|
+
fix="check --subtitle, or run openbbq export --format ass",
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
path = ws.require_artifact(
|
|
97
|
+
workspace,
|
|
98
|
+
manifest,
|
|
99
|
+
Stage.EXPORT,
|
|
100
|
+
fix="openbbq export --format ass",
|
|
101
|
+
)
|
|
102
|
+
if path.suffix.lower() != ".ass":
|
|
103
|
+
raise OpenBBQError(
|
|
104
|
+
"unsupported_subtitle_format",
|
|
105
|
+
artifact=_relative_to_workspace(path, workspace),
|
|
106
|
+
format=path.suffix.lower() or "(none)",
|
|
107
|
+
fix="openbbq export --format ass",
|
|
108
|
+
)
|
|
109
|
+
return path
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _default_output(subtitle: Path) -> Path:
|
|
113
|
+
return subtitle.with_name(f"{subtitle.stem}-burned.mp4")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _record_running(
|
|
117
|
+
workspace: Path, manifest: Manifest, progress: media.BurnProgress
|
|
118
|
+
) -> None:
|
|
119
|
+
ws.record_stage(
|
|
120
|
+
workspace,
|
|
121
|
+
manifest,
|
|
122
|
+
Stage.BURN,
|
|
123
|
+
StageState(
|
|
124
|
+
status=StageStatus.RUNNING,
|
|
125
|
+
progress=Progress(
|
|
126
|
+
done=progress.done,
|
|
127
|
+
total=progress.total,
|
|
128
|
+
label="burn subtitles",
|
|
129
|
+
),
|
|
130
|
+
updated_at=_now(),
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def burn(
|
|
136
|
+
ctx: typer.Context,
|
|
137
|
+
workspace: Annotated[
|
|
138
|
+
str | None,
|
|
139
|
+
typer.Option("--workspace", "-w", help="workspace dir (default: cwd upward)"),
|
|
140
|
+
] = None,
|
|
141
|
+
subtitle: Annotated[
|
|
142
|
+
str | None,
|
|
143
|
+
typer.Option(
|
|
144
|
+
"--subtitle",
|
|
145
|
+
help="ASS subtitle file to burn (default: last export artifact)",
|
|
146
|
+
),
|
|
147
|
+
] = None,
|
|
148
|
+
output: Annotated[
|
|
149
|
+
str | None,
|
|
150
|
+
typer.Option(
|
|
151
|
+
"--output", help="output mp4 path (default: out/<lang>-burned.mp4)"
|
|
152
|
+
),
|
|
153
|
+
] = None,
|
|
154
|
+
ffmpeg: Annotated[
|
|
155
|
+
str | None,
|
|
156
|
+
typer.Option("--ffmpeg", help="ffmpeg executable with libass support"),
|
|
157
|
+
] = None,
|
|
158
|
+
) -> None:
|
|
159
|
+
"""Hard-burn an exported ASS subtitle file into an MP4 video."""
|
|
160
|
+
output_obj: Output = ctx.obj
|
|
161
|
+
started = time.monotonic()
|
|
162
|
+
path = ws.resolve_workspace(workspace)
|
|
163
|
+
manifest = ws.read_manifest(path)
|
|
164
|
+
src = ws.media_input(manifest, path)
|
|
165
|
+
if src.suffix.lower() in ws.AUDIO_EXTS:
|
|
166
|
+
raise OpenBBQError(
|
|
167
|
+
"video_unavailable",
|
|
168
|
+
source=str(src),
|
|
169
|
+
fix="use a video source, or fetch media from the URL first",
|
|
170
|
+
)
|
|
171
|
+
sub = _resolve_subtitle(path, manifest, subtitle)
|
|
172
|
+
dest = _resolve_path(output, path) if output else _default_output(sub)
|
|
173
|
+
|
|
174
|
+
last_write = 0.0
|
|
175
|
+
|
|
176
|
+
def heartbeat(progress: media.BurnProgress) -> None:
|
|
177
|
+
nonlocal last_write
|
|
178
|
+
now = time.monotonic()
|
|
179
|
+
if not (
|
|
180
|
+
now - last_write >= 1.0
|
|
181
|
+
or progress.done == 0
|
|
182
|
+
or progress.done == progress.total
|
|
183
|
+
):
|
|
184
|
+
return
|
|
185
|
+
_record_running(path, manifest, progress)
|
|
186
|
+
last_write = now
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
if output_obj.json_mode:
|
|
190
|
+
outcome = media.burn_subtitles(
|
|
191
|
+
src, sub, dest, ffmpeg=ffmpeg, on_progress=heartbeat
|
|
192
|
+
)
|
|
193
|
+
else:
|
|
194
|
+
progress_bar = RichProgress(
|
|
195
|
+
TextColumn("[progress.description]{task.description}"),
|
|
196
|
+
BarColumn(),
|
|
197
|
+
TextColumn("{task.percentage:>3.0f}%"),
|
|
198
|
+
TimeElapsedColumn(),
|
|
199
|
+
TimeRemainingColumn(),
|
|
200
|
+
transient=True,
|
|
201
|
+
)
|
|
202
|
+
with progress_bar:
|
|
203
|
+
task = progress_bar.add_task("Burning subtitles", total=None)
|
|
204
|
+
|
|
205
|
+
def cb(progress: media.BurnProgress) -> None:
|
|
206
|
+
progress_bar.update(
|
|
207
|
+
task,
|
|
208
|
+
completed=progress.done,
|
|
209
|
+
total=progress.total,
|
|
210
|
+
)
|
|
211
|
+
heartbeat(progress)
|
|
212
|
+
|
|
213
|
+
outcome = media.burn_subtitles(
|
|
214
|
+
src, sub, dest, ffmpeg=ffmpeg, on_progress=cb
|
|
215
|
+
)
|
|
216
|
+
except OpenBBQError as err:
|
|
217
|
+
ws.record_stage(
|
|
218
|
+
path,
|
|
219
|
+
manifest,
|
|
220
|
+
Stage.BURN,
|
|
221
|
+
StageState(
|
|
222
|
+
status=StageStatus.FAILED,
|
|
223
|
+
error=err.code,
|
|
224
|
+
updated_at=_now(),
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
raise
|
|
228
|
+
|
|
229
|
+
artifact = _relative_to_workspace(dest, path)
|
|
230
|
+
subtitle_artifact = _relative_to_workspace(sub, path)
|
|
231
|
+
ws.record_stage(
|
|
232
|
+
path,
|
|
233
|
+
manifest,
|
|
234
|
+
Stage.BURN,
|
|
235
|
+
StageState(
|
|
236
|
+
status=StageStatus.DONE,
|
|
237
|
+
artifact=artifact,
|
|
238
|
+
updated_at=_now(),
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
output_obj.emit(
|
|
242
|
+
BurnResult(
|
|
243
|
+
workspace=str(path),
|
|
244
|
+
artifact=artifact,
|
|
245
|
+
subtitle=subtitle_artifact,
|
|
246
|
+
duration_s=outcome.duration_s,
|
|
247
|
+
elapsed_s=round(time.monotonic() - started, 2),
|
|
248
|
+
ffmpeg=outcome.ffmpeg,
|
|
249
|
+
)
|
|
250
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import RenderableType
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
from ...core import doctor as core
|
|
8
|
+
from ...schemas import OpenBBQModel
|
|
9
|
+
from ..output import Output
|
|
10
|
+
from ..results import Result
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# --- contract layer: doctor's stdout shape + how it renders -------------------
|
|
14
|
+
class CheckReport(OpenBBQModel):
|
|
15
|
+
name: str
|
|
16
|
+
ok: bool
|
|
17
|
+
detail: str
|
|
18
|
+
fix: str | None = None
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def of(cls, c: core.Check) -> CheckReport:
|
|
22
|
+
return cls(name=c.name, ok=c.ok, detail=c.detail, fix=c.fix)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DoctorResult(Result):
|
|
26
|
+
healthy: bool # all probes green (distinct from `ok` = the command ran)
|
|
27
|
+
checks: list[CheckReport]
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def of(cls, checks: list[core.Check]) -> DoctorResult:
|
|
31
|
+
return cls(
|
|
32
|
+
healthy=all(c.ok for c in checks),
|
|
33
|
+
checks=[CheckReport.of(c) for c in checks],
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def render(self) -> RenderableType:
|
|
37
|
+
table = Table(show_header=True, header_style="bold", box=None)
|
|
38
|
+
table.add_column("check")
|
|
39
|
+
table.add_column("status")
|
|
40
|
+
table.add_column("detail")
|
|
41
|
+
for c in self.checks:
|
|
42
|
+
mark = "[green]✓[/]" if c.ok else "[red]✗[/]"
|
|
43
|
+
detail = (
|
|
44
|
+
c.detail if c.ok or c.fix is None else f"{c.detail}\n[dim]fix:[/] {c.fix}"
|
|
45
|
+
)
|
|
46
|
+
table.add_row(c.name, mark, detail)
|
|
47
|
+
return table
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --- shell layer: typer binding only ------------------------------------------
|
|
51
|
+
def doctor(ctx: typer.Context) -> None:
|
|
52
|
+
"""Probe the environment for required tools (read-only, idempotent)."""
|
|
53
|
+
output: Output = ctx.obj
|
|
54
|
+
output.emit(DoctorResult.of(core.run_checks()))
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from ...core import export as exp
|
|
11
|
+
from ...core import workspace as ws
|
|
12
|
+
from ...errors import OpenBBQError
|
|
13
|
+
from ...schemas import Stage, StageState, StageStatus
|
|
14
|
+
from ..output import Output
|
|
15
|
+
from ..results import Result
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# --- contract layer -----------------------------------------------------------
|
|
19
|
+
class ExportResult(Result):
|
|
20
|
+
artifact: str # relative to the workspace, or the given --output
|
|
21
|
+
mode: str # source | target | bilingual
|
|
22
|
+
format: str # subtitle format (srt | ass)
|
|
23
|
+
ass_preset: str | None = None # ASS style preset when format=ass
|
|
24
|
+
cues: int
|
|
25
|
+
elapsed_s: float
|
|
26
|
+
|
|
27
|
+
def render(self) -> str:
|
|
28
|
+
detail = f"{self.cues} cues · {self.format} · {self.mode}"
|
|
29
|
+
if self.ass_preset is not None:
|
|
30
|
+
detail = f"{detail} · {self.ass_preset}"
|
|
31
|
+
return (
|
|
32
|
+
f"[green]✓[/] exported: {self.artifact}\n"
|
|
33
|
+
f" {detail}"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --- shell layer --------------------------------------------------------------
|
|
38
|
+
def export(
|
|
39
|
+
ctx: typer.Context,
|
|
40
|
+
workspace: Annotated[
|
|
41
|
+
str | None,
|
|
42
|
+
typer.Option("--workspace", "-w", help="workspace dir (default: cwd upward)"),
|
|
43
|
+
] = None,
|
|
44
|
+
to: Annotated[
|
|
45
|
+
str | None,
|
|
46
|
+
typer.Option("--to", help="target language worksheet to render (e.g. zh)"),
|
|
47
|
+
] = None,
|
|
48
|
+
mode: Annotated[
|
|
49
|
+
exp.ExportMode | None,
|
|
50
|
+
typer.Option(
|
|
51
|
+
"--mode", help="source | target | bilingual (default: source, or target with --to)"
|
|
52
|
+
),
|
|
53
|
+
] = None,
|
|
54
|
+
fmt: Annotated[
|
|
55
|
+
str, typer.Option("--format", help="subtitle format (srt | ass)")
|
|
56
|
+
] = "srt",
|
|
57
|
+
output: Annotated[
|
|
58
|
+
str | None,
|
|
59
|
+
typer.Option("--output", help="output path (default: out/<lang>.<format>)"),
|
|
60
|
+
] = None,
|
|
61
|
+
ass_preset: Annotated[
|
|
62
|
+
exp.AssPreset,
|
|
63
|
+
typer.Option(
|
|
64
|
+
"--ass-preset",
|
|
65
|
+
help="ASS style preset: default | compact | fansub | mobile",
|
|
66
|
+
),
|
|
67
|
+
] = exp.AssPreset.DEFAULT,
|
|
68
|
+
allow_missing: Annotated[
|
|
69
|
+
bool,
|
|
70
|
+
typer.Option(
|
|
71
|
+
"--allow-missing", help="fall back to source for untranslated cues"
|
|
72
|
+
),
|
|
73
|
+
] = False,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Render cues into a subtitle file."""
|
|
76
|
+
output_obj: Output = ctx.obj
|
|
77
|
+
started = time.monotonic()
|
|
78
|
+
if fmt not in exp.SUPPORTED_FORMATS:
|
|
79
|
+
raise OpenBBQError("unsupported_format", format=fmt, fix="use --format srt or ass")
|
|
80
|
+
if fmt != "ass" and ass_preset != exp.AssPreset.DEFAULT:
|
|
81
|
+
raise OpenBBQError(
|
|
82
|
+
"ass_preset_requires_ass",
|
|
83
|
+
preset=ass_preset.value,
|
|
84
|
+
format=fmt,
|
|
85
|
+
fix="use --format ass, or remove --ass-preset",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
path = ws.resolve_workspace(workspace)
|
|
89
|
+
manifest = ws.read_manifest(path)
|
|
90
|
+
cpath = ws.require_artifact(path, manifest, Stage.SEGMENT, fix="openbbq segment")
|
|
91
|
+
doc = ws.read_cues(cpath)
|
|
92
|
+
|
|
93
|
+
translation = None
|
|
94
|
+
if to is not None:
|
|
95
|
+
wpath = ws.worksheet_path(path, to) # validates lang
|
|
96
|
+
if not wpath.is_file():
|
|
97
|
+
raise OpenBBQError(
|
|
98
|
+
"translation_not_found", lang=to, fix=f"openbbq translate init {to}"
|
|
99
|
+
)
|
|
100
|
+
translation = ws.read_translation(wpath)
|
|
101
|
+
|
|
102
|
+
resolved = mode or exp.default_mode(to)
|
|
103
|
+
if resolved is not exp.ExportMode.SOURCE and translation is None:
|
|
104
|
+
raise OpenBBQError(
|
|
105
|
+
"translation_required", mode=resolved.value, fix="pass --to <lang>"
|
|
106
|
+
)
|
|
107
|
+
if fmt == "ass":
|
|
108
|
+
content = exp.render_ass(
|
|
109
|
+
doc,
|
|
110
|
+
resolved,
|
|
111
|
+
translation=translation,
|
|
112
|
+
allow_missing=allow_missing,
|
|
113
|
+
preset=ass_preset,
|
|
114
|
+
)
|
|
115
|
+
else:
|
|
116
|
+
content = exp.render_srt(
|
|
117
|
+
doc,
|
|
118
|
+
resolved,
|
|
119
|
+
translation=translation,
|
|
120
|
+
allow_missing=allow_missing,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
lang = exp.output_lang(doc, translation, resolved)
|
|
124
|
+
dest = Path(output) if output else Path("out") / f"{lang}.{fmt}"
|
|
125
|
+
if not dest.is_absolute():
|
|
126
|
+
dest = path / dest
|
|
127
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
dest.write_text(content)
|
|
129
|
+
|
|
130
|
+
# record the artifact relative to the workspace when it lives inside it
|
|
131
|
+
try:
|
|
132
|
+
artifact = str(dest.relative_to(path))
|
|
133
|
+
except ValueError:
|
|
134
|
+
artifact = str(dest)
|
|
135
|
+
ws.record_stage(
|
|
136
|
+
path,
|
|
137
|
+
manifest,
|
|
138
|
+
Stage.EXPORT,
|
|
139
|
+
StageState(
|
|
140
|
+
status=StageStatus.DONE,
|
|
141
|
+
artifact=artifact,
|
|
142
|
+
updated_at=datetime.now(timezone.utc),
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
output_obj.emit(
|
|
146
|
+
ExportResult(
|
|
147
|
+
artifact=artifact,
|
|
148
|
+
mode=resolved.value,
|
|
149
|
+
format=fmt,
|
|
150
|
+
ass_preset=ass_preset.value if fmt == "ass" else None,
|
|
151
|
+
cues=len(doc.cues),
|
|
152
|
+
elapsed_s=round(time.monotonic() - started, 2),
|
|
153
|
+
)
|
|
154
|
+
)
|