porter-workflow 0.2.0__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.
Files changed (103) hide show
  1. porter/__init__.py +143 -0
  2. porter/asr/__init__.py +59 -0
  3. porter/asr/base.py +169 -0
  4. porter/asr/bcut.py +304 -0
  5. porter/asr/chain.py +442 -0
  6. porter/asr/google_web.py +296 -0
  7. porter/asr/platform_subs.py +123 -0
  8. porter/asr/videocaptioner.py +241 -0
  9. porter/asr/whisper_api.py +193 -0
  10. porter/asr/whisper_local.py +360 -0
  11. porter/config.py +398 -0
  12. porter/context.py +105 -0
  13. porter/doctor/__init__.py +55 -0
  14. porter/doctor/guides.py +166 -0
  15. porter/doctor/probes.py +673 -0
  16. porter/errors.py +138 -0
  17. porter/events.py +194 -0
  18. porter/jobs/__init__.py +44 -0
  19. porter/jobs/records.py +695 -0
  20. porter/jobs/store.py +332 -0
  21. porter/logging.py +157 -0
  22. porter/media/__init__.py +75 -0
  23. porter/media/burn.py +479 -0
  24. porter/media/encode.py +472 -0
  25. porter/media/enhance.py +102 -0
  26. porter/media/ffmpeg.py +367 -0
  27. porter/media/prepare.py +210 -0
  28. porter/media/probe.py +189 -0
  29. porter/media/standardize.py +199 -0
  30. porter/models/__init__.py +25 -0
  31. porter/models/inspection.py +116 -0
  32. porter/models/materials.py +128 -0
  33. porter/models/metadata.py +47 -0
  34. porter/models/plan.py +113 -0
  35. porter/models/request.py +165 -0
  36. porter/models/subtitle.py +120 -0
  37. porter/pipeline.py +505 -0
  38. porter/plan.py +373 -0
  39. porter/platforms/__init__.py +60 -0
  40. porter/platforms/base.py +746 -0
  41. porter/platforms/bilibili.py +57 -0
  42. porter/platforms/downloader.py +89 -0
  43. porter/platforms/inspector.py +280 -0
  44. porter/platforms/instagram.py +49 -0
  45. porter/platforms/local.py +282 -0
  46. porter/platforms/registry.py +231 -0
  47. porter/platforms/spec.py +158 -0
  48. porter/platforms/tiktok.py +43 -0
  49. porter/platforms/titles.py +132 -0
  50. porter/platforms/urls.py +223 -0
  51. porter/platforms/x.py +37 -0
  52. porter/platforms/ydl.py +431 -0
  53. porter/platforms/youtube.py +47 -0
  54. porter/ports.py +158 -0
  55. porter/py.typed +1 -0
  56. porter/subtitles/__init__.py +71 -0
  57. porter/subtitles/ass.py +274 -0
  58. porter/subtitles/phrasing.py +765 -0
  59. porter/subtitles/srt.py +432 -0
  60. porter/subtitles/transcript.py +286 -0
  61. porter/translate/__init__.py +67 -0
  62. porter/translate/base.py +199 -0
  63. porter/translate/bing.py +467 -0
  64. porter/translate/chain.py +477 -0
  65. porter/translate/google.py +354 -0
  66. porter/translate/llm.py +324 -0
  67. porter/translate/mymemory.py +165 -0
  68. porter/translate/reuse.py +176 -0
  69. porter/translate/videocaptioner.py +273 -0
  70. porter/utils/__init__.py +17 -0
  71. porter/utils/text.py +61 -0
  72. porter/utils/time.py +53 -0
  73. porter_cli/__init__.py +13 -0
  74. porter_cli/__main__.py +17 -0
  75. porter_cli/app.py +131 -0
  76. porter_cli/commands/__init__.py +14 -0
  77. porter_cli/commands/config.py +131 -0
  78. porter_cli/commands/doctor.py +130 -0
  79. porter_cli/commands/inspect.py +86 -0
  80. porter_cli/commands/jobs.py +216 -0
  81. porter_cli/commands/plan.py +198 -0
  82. porter_cli/commands/run.py +277 -0
  83. porter_cli/render.py +226 -0
  84. porter_mcp/__init__.py +22 -0
  85. porter_mcp/limits.py +34 -0
  86. porter_mcp/server.py +90 -0
  87. porter_mcp/shutdown.py +204 -0
  88. porter_mcp/stdout_guard.py +153 -0
  89. porter_mcp/tools/__init__.py +49 -0
  90. porter_mcp/tools/config.py +99 -0
  91. porter_mcp/tools/docs.py +282 -0
  92. porter_mcp/tools/doctor.py +118 -0
  93. porter_mcp/tools/inspect.py +123 -0
  94. porter_mcp/tools/jobs.py +435 -0
  95. porter_mcp/tools/meta.py +40 -0
  96. porter_mcp/tools/plan.py +106 -0
  97. porter_mcp/tools/stages.py +357 -0
  98. porter_workflow-0.2.0.dist-info/METADATA +309 -0
  99. porter_workflow-0.2.0.dist-info/RECORD +103 -0
  100. porter_workflow-0.2.0.dist-info/WHEEL +4 -0
  101. porter_workflow-0.2.0.dist-info/entry_points.txt +3 -0
  102. porter_workflow-0.2.0.dist-info/licenses/LICENSE +21 -0
  103. porter_workflow-0.2.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +175 -0
porter/__init__.py ADDED
@@ -0,0 +1,143 @@
1
+ """porter — automated video localization engine.
2
+
3
+ This package is the **library** layer. It contains no command-line parsing and
4
+ never writes to stdout: two thin frontends consume it.
5
+
6
+ * ``porter_cli`` — the ``porter`` console script, for humans.
7
+ * ``porter_mcp`` — the ``porter-mcp`` MCP server, for AI agents.
8
+
9
+ Architectural rules (enforced by ``lint-imports`` and a test that scans for
10
+ ``print`` calls):
11
+
12
+ 1. This package must never import ``porter_cli`` or ``porter_mcp``.
13
+ 2. Nothing under this package may call ``print()`` — in an MCP stdio server,
14
+ stdout *is* the JSON-RPC channel. Use :func:`porter.logging.get_logger`.
15
+ 3. Nothing in this package may assume a deployment layout (agent skill
16
+ directories, caller-supplied config paths, ...). Configuration is resolved
17
+ from explicit arguments, environment variables and platform user dirs.
18
+
19
+ Importing this module is cheap and dependency-free. The heavier symbols
20
+ (``JobOptions``, ``Pipeline``, ...) are resolved lazily on first attribute
21
+ access via :pep:`562` — so ``import porter; porter.__version__`` needs nothing
22
+ but the standard library.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import TYPE_CHECKING, Any
28
+
29
+ from porter.errors import (
30
+ AsrError,
31
+ CapabilityMissingError,
32
+ ConfigError,
33
+ ExtractionError,
34
+ JobCancelled,
35
+ MediaError,
36
+ PorterError,
37
+ RenderError,
38
+ SubtitleError,
39
+ TranslationError,
40
+ UnsupportedPlatformError,
41
+ )
42
+ from porter.logging import configure as configure_logging
43
+ from porter.logging import get_logger
44
+
45
+ __version__ = "0.2.0"
46
+
47
+ #: Public API, resolved lazily. Maps attribute name -> defining module.
48
+ _LAZY_EXPORTS: dict[str, str] = {
49
+ # context
50
+ "RunContext": "porter.context",
51
+ # events
52
+ "ArtifactKind": "porter.events",
53
+ "Event": "porter.events",
54
+ "EventSink": "porter.events",
55
+ "JobState": "porter.events",
56
+ "Phase": "porter.events",
57
+ # models
58
+ "BurnMode": "porter.models",
59
+ "BurnResult": "porter.models",
60
+ "JobOptions": "porter.models",
61
+ "JobRequest": "porter.models",
62
+ "JobResult": "porter.models",
63
+ "RawMaterials": "porter.models",
64
+ "SubtitleItem": "porter.models",
65
+ "SubtitleSet": "porter.models",
66
+ "TaskLayout": "porter.models",
67
+ "TranscriptSentence": "porter.models",
68
+ "VideoMetadata": "porter.models",
69
+ # orchestration
70
+ "Pipeline": "porter.pipeline",
71
+ }
72
+
73
+ if TYPE_CHECKING: # pragma: no cover - import-time only, for type checkers
74
+ from porter.context import RunContext
75
+ from porter.events import ArtifactKind, Event, EventSink, JobState, Phase
76
+ from porter.models import (
77
+ BurnMode,
78
+ BurnResult,
79
+ JobOptions,
80
+ JobRequest,
81
+ JobResult,
82
+ RawMaterials,
83
+ SubtitleItem,
84
+ SubtitleSet,
85
+ TaskLayout,
86
+ TranscriptSentence,
87
+ VideoMetadata,
88
+ )
89
+ from porter.pipeline import Pipeline
90
+
91
+
92
+ def __getattr__(name: str) -> Any:
93
+ """Resolve the lazily-exported public API (PEP 562)."""
94
+ module_path = _LAZY_EXPORTS.get(name)
95
+ if module_path is None:
96
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
97
+
98
+ import importlib
99
+
100
+ module = importlib.import_module(module_path)
101
+ value = getattr(module, name)
102
+ globals()[name] = value # cache so subsequent lookups skip __getattr__
103
+ return value
104
+
105
+
106
+ def __dir__() -> list[str]:
107
+ return sorted({*globals(), *_LAZY_EXPORTS})
108
+
109
+
110
+ __all__ = [
111
+ "ArtifactKind",
112
+ "AsrError",
113
+ "BurnMode",
114
+ "BurnResult",
115
+ "CapabilityMissingError",
116
+ "ConfigError",
117
+ "Event",
118
+ "EventSink",
119
+ "ExtractionError",
120
+ "JobCancelled",
121
+ "JobOptions",
122
+ "JobRequest",
123
+ "JobResult",
124
+ "JobState",
125
+ "MediaError",
126
+ "Phase",
127
+ "Pipeline",
128
+ "PorterError",
129
+ "RawMaterials",
130
+ "RenderError",
131
+ "RunContext",
132
+ "SubtitleError",
133
+ "SubtitleItem",
134
+ "SubtitleSet",
135
+ "TaskLayout",
136
+ "TranscriptSentence",
137
+ "TranslationError",
138
+ "UnsupportedPlatformError",
139
+ "VideoMetadata",
140
+ "__version__",
141
+ "configure_logging",
142
+ "get_logger",
143
+ ]
porter/asr/__init__.py ADDED
@@ -0,0 +1,59 @@
1
+ """Speech-to-text backends and the fallback chain.
2
+
3
+ ``chain.py``
4
+ :class:`AsrChain` — implements :class:`~porter.ports.Transcriber`. Takes the
5
+ platform's own subtitle track when PREPARE fetched one, and otherwise walks
6
+ the engine chain, skipping backends whose ``available()`` is ``False``.
7
+ ``platform_subs.py``
8
+ Reads that fetched track. Deliberately **not** a backend: it is a file, with
9
+ no engine to probe and no ordering question.
10
+ ``whisper_api.py``
11
+ OpenAI-compatible Whisper endpoint. Requires the ``[llm]`` extra.
12
+ ``whisper_local.py``
13
+ ★ Local Whisper through ``faster-whisper`` (MIT, no PyTorch). Requires the
14
+ ``[asr-local]`` extra. The only backend that needs no key, no network and no
15
+ third-party service, and the only one whose ``endpoint_verified`` is true by
16
+ construction rather than by measurement.
17
+ ``bcut.py``
18
+ Bilibili Bcut. Key-free, chunked upload, its own session so a configured proxy
19
+ cannot stall the upload. **Unverified endpoint.**
20
+ ``google_web.py``
21
+ Google Web STT with recursive VAD slicing, so long dialogue does not lose its
22
+ tail. Requires the ``[stt]`` extra. **Unverified endpoint.**
23
+ ``videocaptioner.py``
24
+ ★ Optional adapter around the **external** ``videocaptioner`` CLI.
25
+ GPL-3.0 and ``python<3.13``, therefore never a declared dependency and only
26
+ ever reached through ``subprocess``. An absent binary drops the backend from
27
+ the chain without a user-visible warning.
28
+ ``base.py``
29
+ The backend protocol, the shared :class:`~porter.asr.base.AsrOutcome`, and the
30
+ cue helpers every backend needs.
31
+
32
+ "Unverified endpoint" is a claim about the *wire format*, not the structure. Those
33
+ two modules port v0.1's request/response handling for services that were
34
+ reverse-engineered rather than documented; they cannot be exercised in an offline
35
+ test suite and may already be dead. Their availability probes, error mapping,
36
+ timeouts and cancellation handling are deliberate and tested.
37
+ """
38
+
39
+ from porter.asr.base import AsrBackend, AsrBackendError, AsrOutcome
40
+ from porter.asr.bcut import BcutBackend
41
+ from porter.asr.chain import AsrChain
42
+ from porter.asr.google_web import GoogleWebBackend
43
+ from porter.asr.platform_subs import load_platform_subtitles
44
+ from porter.asr.videocaptioner import VideoCaptionerBackend
45
+ from porter.asr.whisper_api import WhisperApiBackend
46
+ from porter.asr.whisper_local import WhisperLocalBackend
47
+
48
+ __all__ = [
49
+ "AsrBackend",
50
+ "AsrBackendError",
51
+ "AsrChain",
52
+ "AsrOutcome",
53
+ "BcutBackend",
54
+ "GoogleWebBackend",
55
+ "VideoCaptionerBackend",
56
+ "WhisperApiBackend",
57
+ "WhisperLocalBackend",
58
+ "load_platform_subtitles",
59
+ ]
porter/asr/base.py ADDED
@@ -0,0 +1,169 @@
1
+ """The narrow contract every ASR engine implements.
2
+
3
+ :class:`~porter.ports.AsrBackend` in ``porter.ports`` is the *pipeline's* view;
4
+ this module is the *backend author's* view, plus the shared error type and the
5
+ one helper every backend needs.
6
+
7
+ ## Why a separate error type
8
+
9
+ The chain has to answer "should I try the next backend?" and it cannot do that
10
+ from an arbitrary exception. A backend that returns an empty list, one that
11
+ raises ``TimeoutError``, and one that parses a response into nothing all look the
12
+ same from outside, and the difference decides whether falling through is right.
13
+
14
+ So backends raise :class:`AsrBackendError` for *expected* failure — endpoint
15
+ down, quota exhausted, malformed response — and let genuinely unexpected
16
+ exceptions (a bug in our own code) propagate. The chain catches the first and
17
+ logs the second, which keeps a typo from being silently swallowed by a fallback
18
+ loop.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import Protocol, runtime_checkable
26
+
27
+ from porter.context import RunContext
28
+ from porter.models.subtitle import SubtitleItem
29
+
30
+ __all__ = [
31
+ "CHUNK_SECONDS",
32
+ "MAX_AUDIO_BYTES",
33
+ "AsrBackend",
34
+ "AsrBackendError",
35
+ "AsrOutcome",
36
+ "coerce_items",
37
+ "parse_srt_items",
38
+ ]
39
+
40
+ #: Upload chunk ceiling used by the cloud backends. Bilibili's endpoint rejects
41
+ #: anything larger; the others are happy with it.
42
+ MAX_AUDIO_BYTES = 100 * 1024 * 1024
43
+
44
+ #: Long audio is sliced rather than uploaded whole, because the free endpoints
45
+ #: time out on a 40-minute file and the failure looks like a network problem.
46
+ CHUNK_SECONDS = 480.0
47
+
48
+
49
+ class AsrBackendError(Exception):
50
+ """An expected backend failure. The chain moves on to the next engine.
51
+
52
+ Carries ``backend`` so the log line names the engine that failed rather than
53
+ reporting a bare message, which matters when five backends have each failed
54
+ for a different reason.
55
+
56
+ ## The rule every backend must obey
57
+
58
+ **Every expected failure becomes an ``AsrBackendError``.** The chain catches
59
+ exactly ``AsrBackendError`` and ``PorterError``; anything else propagates and
60
+ kills the process with a traceback.
61
+
62
+ That is deliberate -- a chain-wide ``except Exception`` would swallow real
63
+ bugs (a typo, a bad unpack) and report them as "backend unavailable", which is
64
+ the hardest kind of failure to diagnose. But it puts the burden on the
65
+ backend, and transport layers are where it gets missed: ``requests`` raises
66
+ ``RequestException``, ``urllib`` raises ``http.client.HTTPException`` (a
67
+ truncated chunked response is ``IncompleteRead``, which is neither an
68
+ ``OSError`` nor a ``RequestError``), and a library may raise something else
69
+ entirely. Map them all.
70
+ """
71
+
72
+ def __init__(self, backend: str, message: str, /, **details: object) -> None:
73
+ super().__init__(message)
74
+ self.backend = backend
75
+ self.message = message
76
+ self.details = details
77
+
78
+ def __str__(self) -> str:
79
+ base = f"[{self.backend}] {self.message}"
80
+ if self.details:
81
+ rendered = ", ".join(f"{key}={value!r}" for key, value in self.details.items())
82
+ return f"{base} ({rendered})"
83
+ return base
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class AsrOutcome:
88
+ """What a backend produced. Cues plus an optional provenance note.
89
+
90
+ ``used_asr`` is on the outcome rather than inferred by the chain because only
91
+ the backend knows: a platform-subtitle backend did no recognition at all, and
92
+ reporting that as "ASR was used" would mislabel the source for the operator.
93
+ """
94
+
95
+ items: list[SubtitleItem]
96
+ used_asr: bool = True
97
+ #: Free-text provenance for the log and the job report, e.g. "whisper-1".
98
+ origin: str = ""
99
+
100
+
101
+ @runtime_checkable
102
+ class AsrBackend(Protocol):
103
+ """One speech-to-text engine.
104
+
105
+ ``available()`` must be cheap and must never raise — it is called for every
106
+ backend on every job to decide the chain, so a backend that raises there
107
+ breaks the chain's ability to skip it.
108
+ """
109
+
110
+ name: str
111
+
112
+ #: Whether this engine's wire format has been verified against the live
113
+ #: service.
114
+ #:
115
+ #: ``available()`` cannot answer this: it probes *local* facts (a key is set, a
116
+ #: binary is on PATH), which say nothing about whether the remote endpoint
117
+ #: still speaks the protocol this code was written for. ``bcut`` and
118
+ #: ``google_web`` are reverse-engineered and answered with empty results on
119
+ #: every probe made while building this port, so they report ``available`` and
120
+ #: still cannot transcribe.
121
+ #:
122
+ #: ``False`` is not "broken" -- it is "nobody has checked", which is exactly
123
+ #: what a caller needs to know before promising a user that a job will finish.
124
+ endpoint_verified: bool
125
+
126
+ def available(self, ctx: RunContext) -> bool:
127
+ """Whether this engine can run right now. Never raises."""
128
+ ...
129
+
130
+ def transcribe(self, audio: Path, ctx: RunContext) -> AsrOutcome:
131
+ """Recognise ``audio``.
132
+
133
+ Raises:
134
+ AsrBackendError: For expected failure. The chain tries the next
135
+ backend.
136
+ """
137
+ ...
138
+
139
+
140
+ def coerce_items(items: list[SubtitleItem]) -> list[SubtitleItem]:
141
+ """Renumber cues 1..n and drop empties.
142
+
143
+ Every backend needs this and each v0.1 copy did it slightly differently — one
144
+ kept the original indices, one renumbered, one left blank cues in. Renumbering
145
+ is the correct choice: the generators write ``index`` into the SRT, and a
146
+ duplicated or gapped index produces a file that some players refuse.
147
+ """
148
+ cleaned = [
149
+ item
150
+ for item in items
151
+ if item.source_text.strip() and item.end_ms > item.start_ms
152
+ ]
153
+ for position, item in enumerate(cleaned, start=1):
154
+ item.index = position
155
+ return cleaned
156
+
157
+
158
+ def parse_srt_items(text: str) -> list[SubtitleItem]:
159
+ """Parse SRT into cues. Returns ``[]`` for anything unparseable.
160
+
161
+ Deliberately total: a backend that returns a malformed body should fall
162
+ through the chain, not raise a parse error that reads like a bug in porter.
163
+ """
164
+ from porter.subtitles.srt import parse_srt
165
+
166
+ try:
167
+ return coerce_items(parse_srt(text))
168
+ except (ValueError, IndexError): # pragma: no cover - parse_srt is total
169
+ return []
porter/asr/bcut.py ADDED
@@ -0,0 +1,304 @@
1
+ """UNVERIFIED ENDPOINT. This ports v0.1's request/response handling for a
2
+ key-free endpoint that was reverse-engineered, not documented. It cannot be
3
+ exercised in an offline test suite and may already be dead. The structure
4
+ (availability probe, error mapping, cancellation, timeout) is deliberate and
5
+ tested; the wire format is best-effort and unvalidated.
6
+
7
+ Measured 2026-09-22: the API host answers (``resource/create`` returns HTTP 200),
8
+ but a real 10-minute transcription produced no usable utterances, so this path is
9
+ not working either. Unlike Google Web, the failure here is ambiguous -- it could
10
+ be a quota, a changed field, or a rejected upload -- so this module keeps its
11
+ "unverified" label rather than claiming the endpoint is dead.
12
+
13
+ Bilibili's Bcut speech-to-text. It requires no key and no account, which is why
14
+ it sits in the free end of the fallback chain, but the ``rubick-interface`` API
15
+ is undocumented: the field names (``in_boss_key``, ``ResourceFileType``,
16
+ ``Etags``) are Bilibili's own capitalisation, and the polling ``model_id`` is
17
+ ``7`` while every other step sends ``8``. Both quirks are preserved verbatim
18
+ because "fixing" an undocumented protocol is how you get an endpoint that
19
+ returns 200 and does nothing.
20
+
21
+ The session deliberately sets ``trust_env = False``. Bilibili's object storage
22
+ is domestic (China), and a developer's corporate or VPN proxy turns a 3-second
23
+ chunk upload into a stall that looks like an endpoint outage.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ import requests
33
+
34
+ from porter.asr.base import AsrBackendError, AsrOutcome, coerce_items
35
+ from porter.context import RunContext
36
+ from porter.logging import get_logger
37
+ from porter.models.subtitle import SubtitleItem
38
+
39
+ __all__ = [
40
+ "API_BASE",
41
+ "MODEL_ID",
42
+ "POLL_ATTEMPTS",
43
+ "POLL_INTERVAL",
44
+ "BcutBackend",
45
+ ]
46
+
47
+ _logger = get_logger(__name__)
48
+
49
+ NAME = "bcut"
50
+
51
+ #: v0.1's endpoint, model id, retry budget and interval, unchanged.
52
+ API_BASE = "https://member.bilibili.com/x/bcut/rubick-interface"
53
+ MODEL_ID = "8"
54
+ POLL_ATTEMPTS = 60
55
+ POLL_INTERVAL = 2.0
56
+
57
+ #: v0.1's per-request timeouts, unchanged: the small control calls get 15-20s
58
+ #: and the chunk upload gets 60s.
59
+ TIMEOUT_CREATE = 15.0
60
+ TIMEOUT_UPLOAD = 60.0
61
+ TIMEOUT_COMMIT = 20.0
62
+ TIMEOUT_TASK = 20.0
63
+ TIMEOUT_RESULT = 15.0
64
+
65
+ _HEADERS = {
66
+ "User-Agent": "Bilibili/1.0.0 (https://www.bilibili.com)",
67
+ "Content-Type": "application/json",
68
+ }
69
+
70
+
71
+ def _data_object(response: requests.Response, what: str) -> dict[str, Any]:
72
+ """Extract the ``data`` object from a Bilibili envelope.
73
+
74
+ Bilibili answers ``{"code": 0, "data": {...}}`` and encodes failures inside
75
+ ``code`` even at HTTP 200, so a missing ``data`` is a real failure rather
76
+ than a reason to read a default.
77
+ """
78
+ try:
79
+ body = response.json()
80
+ except ValueError as exc:
81
+ raise AsrBackendError(NAME, f"{what} returned malformed JSON: {exc}") from exc
82
+ if not isinstance(body, dict):
83
+ raise AsrBackendError(NAME, f"{what} returned a non-object JSON body")
84
+ data = body.get("data")
85
+ if not isinstance(data, dict):
86
+ raise AsrBackendError(NAME, f"{what} response is missing the data object")
87
+ return data
88
+
89
+
90
+ class BcutBackend:
91
+ """Bilibili Bcut. Chunked upload, then poll for the transcript."""
92
+
93
+ name = NAME
94
+ endpoint_verified = False
95
+
96
+ def available(self, ctx: RunContext) -> bool:
97
+ """Whether ``requests`` is importable. Never raises.
98
+
99
+ Bcut needs no key, so there is nothing else to probe. The check is
100
+ written against the module attribute rather than an import statement so
101
+ a host (or a test) that has torn the dependency out degrades to
102
+ ``False`` instead of raising.
103
+ """
104
+ try:
105
+ return bool(getattr(requests, "Session", None))
106
+ except Exception: # available() must never raise; log and degrade.
107
+ _logger.error("could not inspect the requests module; Bcut unavailable", exc_info=True)
108
+ return False
109
+
110
+ def transcribe(self, audio: Path, ctx: RunContext) -> AsrOutcome:
111
+ """Upload ``audio`` to Bcut and return its utterances as cues.
112
+
113
+ Raises:
114
+ AsrBackendError: A missing/empty audio file, a transport or HTTP
115
+ failure, a malformed envelope, or an exhausted poll budget.
116
+ """
117
+ ctx.check_cancelled()
118
+ if getattr(requests, "Session", None) is None:
119
+ raise AsrBackendError(
120
+ self.name,
121
+ "the 'requests' package is required for the Bcut backend",
122
+ )
123
+
124
+ if not audio.exists():
125
+ raise AsrBackendError(self.name, f"audio file does not exist: {audio}")
126
+ try:
127
+ audio_data = audio.read_bytes()
128
+ except OSError as exc:
129
+ raise AsrBackendError(self.name, f"audio file could not be read: {exc}") from exc
130
+ if not audio_data:
131
+ raise AsrBackendError(self.name, "audio file is empty", path=str(audio))
132
+
133
+ # trust_env=False: see the module docstring. A configured proxy stalls on
134
+ # Bilibili's domestic object storage.
135
+ session = requests.Session()
136
+ session.trust_env = False
137
+
138
+ try:
139
+ download_url = self._upload(session, audio, audio_data)
140
+ task_id = self._create_task(session, download_url)
141
+ result = self._poll(session, task_id, ctx)
142
+ except requests.RequestException as exc:
143
+ raise AsrBackendError(self.name, f"Bcut request failed: {exc}") from exc
144
+ except ValueError as exc:
145
+ raise AsrBackendError(self.name, f"Bcut returned malformed JSON: {exc}") from exc
146
+
147
+ return self._outcome(result)
148
+
149
+ # -- steps --------------------------------------------------------------
150
+
151
+ def _upload(self, session: Any, audio: Path, audio_data: bytes) -> str:
152
+ """Steps 1-3: create the resource, PUT each chunk, commit the upload."""
153
+ payload = json.dumps(
154
+ {
155
+ "type": 2,
156
+ "name": audio.name,
157
+ "size": len(audio_data),
158
+ "ResourceFileType": audio.suffix.lstrip(".") or "wav",
159
+ "model_id": MODEL_ID,
160
+ }
161
+ )
162
+ resp = session.post(
163
+ f"{API_BASE}/resource/create",
164
+ data=payload,
165
+ headers=_HEADERS,
166
+ timeout=TIMEOUT_CREATE,
167
+ )
168
+ resp.raise_for_status()
169
+ data = _data_object(resp, "resource/create")
170
+ if "upload_urls" not in data:
171
+ raise AsrBackendError(self.name, "resource/create did not return upload_urls")
172
+
173
+ upload_urls = data["upload_urls"]
174
+ per_size = data["per_size"]
175
+ clips = len(upload_urls)
176
+
177
+ etags: list[str] = []
178
+ for clip in range(clips):
179
+ start = clip * per_size
180
+ end = (clip + 1) * per_size
181
+ part_resp = session.put(
182
+ upload_urls[clip],
183
+ data=audio_data[start:end],
184
+ headers=_HEADERS,
185
+ timeout=TIMEOUT_UPLOAD,
186
+ )
187
+ part_resp.raise_for_status()
188
+ etag = part_resp.headers.get("Etag")
189
+ if etag:
190
+ etags.append(etag)
191
+
192
+ commit_data = json.dumps(
193
+ {
194
+ "InBossKey": data["in_boss_key"],
195
+ "ResourceId": data["resource_id"],
196
+ "Etags": ",".join(etags),
197
+ "UploadId": data["upload_id"],
198
+ "model_id": MODEL_ID,
199
+ }
200
+ )
201
+ commit_resp = session.post(
202
+ f"{API_BASE}/resource/create/complete",
203
+ data=commit_data,
204
+ headers=_HEADERS,
205
+ timeout=TIMEOUT_COMMIT,
206
+ )
207
+ commit_resp.raise_for_status()
208
+ download_url = _data_object(commit_resp, "resource/create/complete").get("download_url")
209
+ if not download_url:
210
+ raise AsrBackendError(self.name, "resource/create/complete did not return a download_url")
211
+ return str(download_url)
212
+
213
+ def _create_task(self, session: Any, download_url: str) -> str:
214
+ """Step 4: submit the transcription task."""
215
+ task_resp = session.post(
216
+ f"{API_BASE}/task",
217
+ data=json.dumps({"resource": download_url, "model_id": MODEL_ID}),
218
+ headers=_HEADERS,
219
+ timeout=TIMEOUT_TASK,
220
+ )
221
+ task_resp.raise_for_status()
222
+ task_id = _data_object(task_resp, "task").get("task_id")
223
+ if not task_id:
224
+ raise AsrBackendError(self.name, "task creation did not return a task_id")
225
+ return str(task_id)
226
+
227
+ def _poll(self, session: Any, task_id: str, ctx: RunContext) -> dict[str, Any]:
228
+ """Step 5: poll until ``state == 4``.
229
+
230
+ ``ctx.cancel.wait`` replaces v0.1's bare ``time.sleep(2)`` so that Ctrl-C
231
+ interrupts the 120-second budget on the next tick instead of after it.
232
+ ``model_id`` is ``7`` here, matching v0.1 and the endpoint, not the
233
+ ``8`` used everywhere else.
234
+ """
235
+ for _ in range(POLL_ATTEMPTS):
236
+ if ctx.cancel.wait(POLL_INTERVAL):
237
+ ctx.check_cancelled()
238
+ ctx.check_cancelled()
239
+
240
+ q_resp = session.get(
241
+ f"{API_BASE}/task/result",
242
+ params={"model_id": 7, "task_id": task_id},
243
+ headers=_HEADERS,
244
+ timeout=TIMEOUT_RESULT,
245
+ )
246
+ q_resp.raise_for_status()
247
+ q_data = _data_object(q_resp, "task/result")
248
+ if q_data.get("state") == 4:
249
+ result = q_data.get("result", "{}")
250
+ parsed = json.loads(result) if isinstance(result, str) else result
251
+ if not isinstance(parsed, dict):
252
+ raise AsrBackendError(self.name, "task/result result is not a JSON object")
253
+ return parsed
254
+
255
+ raise AsrBackendError(
256
+ self.name,
257
+ f"Bcut did not finish within {POLL_ATTEMPTS * POLL_INTERVAL:.0f}s",
258
+ task_id=task_id,
259
+ )
260
+
261
+ # -- result -------------------------------------------------------------
262
+
263
+ def _outcome(self, result: dict[str, Any]) -> AsrOutcome:
264
+ """Turn the ``utterances`` array into cues.
265
+
266
+ v0.1 built an SRT string here and returned ``False`` for an empty result.
267
+ Returning ``AsrOutcome`` with no items is not possible: the chain reads
268
+ an empty list as "this backend produced nothing", which loses *why*, so
269
+ an empty or malformed payload is an explicit failure.
270
+ """
271
+ utterances = result.get("utterances")
272
+ if not isinstance(utterances, list):
273
+ raise AsrBackendError(self.name, "Bcut result is missing the utterances array")
274
+
275
+ items: list[SubtitleItem] = []
276
+ for utterance in utterances:
277
+ if not isinstance(utterance, dict):
278
+ continue
279
+ text = str(utterance.get("transcript", "")).strip()
280
+ if not text:
281
+ continue
282
+ try:
283
+ start_ms = int(utterance.get("start_time", 0))
284
+ end_ms = int(utterance.get("end_time", 0))
285
+ except (TypeError, ValueError) as exc:
286
+ raise AsrBackendError(
287
+ self.name,
288
+ f"Bcut utterance has non-numeric timing: {exc}",
289
+ ) from exc
290
+ items.append(
291
+ SubtitleItem(
292
+ index=len(items) + 1,
293
+ start_ms=start_ms,
294
+ end_ms=end_ms,
295
+ source_text=text,
296
+ target_text="",
297
+ )
298
+ )
299
+
300
+ cleaned = coerce_items(items)
301
+ if not cleaned:
302
+ raise AsrBackendError(self.name, "Bcut returned no usable utterances")
303
+
304
+ return AsrOutcome(items=cleaned, used_asr=True, origin="bcut")