Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/scripto/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ def printer(event: Event) -> None:
export_dir=export_dir,
suffix_map=dict(config["lang_suffixes"]),
memory_mode=config["memory_mode"],
icloud_evict=config["icloud_evict"],
engine_label=engine_name,
segment_threshold_sec=float(config["segment_threshold_sec"]),
segment_chunk_sec=float(config["segment_chunk_sec"]),
Expand Down
3 changes: 3 additions & 0 deletions src/scripto/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@
"translate_batch_max_chars": 3000,
# Pipeline
"memory_mode": "balanced", # balanced | low
# iCloud files are downloaded on demand; True puts the ones we downloaded
# back to cloud-only afterwards, leaving the folder as we found it.
"icloud_evict": True,
"segment_threshold_sec": 3600, # chunk files longer than this (0 = off)
"segment_chunk_sec": 1800,
# Output (R4): None = write next to the source file
Expand Down
6 changes: 6 additions & 0 deletions src/scripto/core/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ class StatusEvent(Event):
subject: str
status: str
detail: str = ""
# The same message as an i18n key and its params, when the failure came
# from a ScriptoError. UI layers render this and fall back to `detail`,
# which is always English — core never imports a catalog. Params are a
# tuple of pairs so the event stays frozen and hashable.
detail_key: str = ""
detail_params: tuple[tuple[str, str], ...] = ()


@dataclass(frozen=True)
Expand Down
5 changes: 4 additions & 1 deletion src/scripto/core/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

class JobStatus(StrEnum):
PENDING = "pending"
DOWNLOADING = "downloading" # pulling an iCloud file onto local disk
EXTRACTING = "extracting"
TRANSCRIBING = "transcribing"
TRANSLATING = "translating"
Expand All @@ -23,7 +24,9 @@ class Job:
id: int
source: Path
status: JobStatus = JobStatus.PENDING
error: str = ""
error: str = "" # always English; see error_key
error_key: str = "" # i18n key when the failure carried one
error_params: tuple[tuple[str, str], ...] = ()
language: str | None = None # detected (or forced) audio language
outputs: list[Path] = field(default_factory=list)

Expand Down
46 changes: 40 additions & 6 deletions src/scripto/core/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ class PipelineSettings:
export_dir: Path | None = None
suffix_map: dict[str, str] = field(default_factory=lambda: dict(out.DEFAULT_SUFFIXES))
memory_mode: str = "balanced" # balanced | low
# Put an iCloud file back to cloud-only once its audio is extracted —
# only ever files this run downloaded, never ones already on disk.
icloud_evict: bool = True
cache_dir: Path | None = None
engine_label: str = "" # for history records
# Very-long-file segmentation: flatten the per-file memory peak by
Expand Down Expand Up @@ -175,9 +178,26 @@ def _extract_worker(self, jobs: list[Job], extract_q: queue.Queue, stop: threadi
self._put(extract_q, (job, None, existing), stop)
continue

job.status = JobStatus.EXTRACTING
self._emit_status(job)
# An iCloud file that isn't downloaded yet is not a failure:
# pull it down, use it, and put it back where we found it so
# a batch of lecture recordings doesn't fill the disk.
in_cloud = access.needs_download(job.source)
borrowed = in_cloud and self._s.icloud_evict
try:
if in_cloud:
job.status = JobStatus.DOWNLOADING
self._emit_status(job)
access.materialize(
job.source,
stop_check=stop.is_set,
on_progress=lambda done, total, j=job: self._bus.emit(
ProgressEvent(
scope=f"download:{j.id}", done=done, total=total
)
),
)
job.status = JobStatus.EXTRACTING
self._emit_status(job)
access.check_readable(job.source)
wav = ffmpeg.extract_audio(
job.source,
Expand All @@ -188,9 +208,14 @@ def _extract_worker(self, jobs: list[Job], extract_q: queue.Queue, stop: threadi
job.status = JobStatus.PENDING
break
except Exception as exc:
self._fail(job, f"extract: {exc}")
self._fail(job, f"extract: {exc}", exc)
self._put(extract_q, (job, None, None), stop)
continue
finally:
# Runs on every exit including break/continue: the audio
# is in the cache by now, the source is dead weight.
if borrowed:
access.evict(job.source)
if not self._put(extract_q, (job, wav, None), stop):
wav.unlink(missing_ok=True)
break
Expand Down Expand Up @@ -275,7 +300,7 @@ def _transcribe_loop(
except OperationStopped:
job.status = JobStatus.PENDING
except Exception as exc:
self._fail(job, str(exc))
self._fail(job, str(exc), exc)
stats.failed += 1
finally:
if wav:
Expand Down Expand Up @@ -406,9 +431,12 @@ def _translate_one(self, job: Job, srt_path: Path, stop: threading.Event) -> Non
# Helpers
# ------------------------------------------------------------------ #

def _fail(self, job: Job, reason: str) -> None:
def _fail(self, job: Job, reason: str, exc: BaseException | None = None) -> None:
job.status = JobStatus.FAILED
job.error = reason
if isinstance(exc, ScriptoError) and exc.key:
job.error_key = exc.key
job.error_params = tuple((k, str(v)) for k, v in exc.params.items())
self._emit_status(job)
self._record(job)
logger.warning("job failed: %s — %s", job.source, reason)
Expand Down Expand Up @@ -486,5 +514,11 @@ def _record(self, job: Job, duration: float = 0.0) -> None:

def _emit_status(self, job: Job) -> None:
self._bus.emit(
StatusEvent(subject=f"job:{job.id}", status=job.status.value, detail=job.error)
StatusEvent(
subject=f"job:{job.id}",
status=job.status.value,
detail=job.error,
detail_key=job.error_key,
detail_params=job.error_params,
)
)
7 changes: 6 additions & 1 deletion src/scripto/gui/viewmodel.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ class FileRow:
id: int
path: Path
status: str = JobStatus.PENDING.value
error: str = ""
error: str = "" # English fallback
error_key: str = "" # preferred: rendered through i18n
error_params: tuple[tuple[str, str], ...] = ()
progress: float = 0.0 # 0..1 within the active stage
stage: str = "" # "transcribe" | "translate" | ""

Expand Down Expand Up @@ -233,6 +235,7 @@ def _build_pipeline(self, config: dict, *, overwrite: bool | None) -> Pipeline:
export_dir=Path(config["export_dir"]).expanduser() if config["export_dir"] else None,
suffix_map=dict(config["lang_suffixes"]),
memory_mode=config["memory_mode"],
icloud_evict=config["icloud_evict"],
engine_label=engine_name,
segment_threshold_sec=float(config.get("segment_threshold_sec", 3600)),
segment_chunk_sec=float(config.get("segment_chunk_sec", 1800)),
Expand Down Expand Up @@ -260,6 +263,8 @@ def drain(self) -> DrainResult:
self._track_eta(row, event.status)
row.status = event.status
row.error = event.detail
row.error_key = event.detail_key
row.error_params = event.detail_params
if event.status in (
JobStatus.DONE.value, JobStatus.SKIPPED.value, JobStatus.FAILED.value
):
Expand Down
24 changes: 23 additions & 1 deletion src/scripto/gui_qt/run_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@

STATUS_ROLES = {
JobStatus.PENDING.value: "subtext",
JobStatus.DOWNLOADING.value: "running",
JobStatus.EXTRACTING.value: "running",
JobStatus.TRANSCRIBING.value: "running",
JobStatus.TRANSLATING.value: "running",
Expand All @@ -48,6 +49,7 @@
}

ACTIVE_STATUSES = (
JobStatus.DOWNLOADING.value,
JobStatus.EXTRACTING.value,
JobStatus.TRANSCRIBING.value,
JobStatus.TRANSLATING.value,
Expand Down Expand Up @@ -141,7 +143,7 @@ def apply(self, page: "RunPage", row) -> None:

failed = row.status == JobStatus.FAILED.value
done = row.status in (JobStatus.DONE.value, JobStatus.SKIPPED.value)
self.error_label.setText(row.error)
self.error_label.setText(page.error_text(row))
self.error_label.setStyleSheet(f"color: {palette.error}; font-size: 11px;")
self.error_label.setVisible(failed and bool(row.error))
self.retry_btn.setVisible(failed)
Expand Down Expand Up @@ -453,6 +455,26 @@ def rebuild_rows(self) -> None:
self.row_widgets[row_id] = widget
self.rows_box.insertWidget(self.rows_box.count() - 1, widget)

def error_text(self, row) -> str:
"""A failure in the user's language when core gave us a key for it.

Core raises English messages plus an i18n key (R7); ``row.error`` is
the English one and only shows through for failures that carry no
key, such as a raw ffmpeg or engine message.
"""
if not row.error_key:
return row.error
try:
text = self.t(row.error_key, **dict(row.error_params))
except (KeyError, IndexError, ValueError):
return row.error # a template/params mismatch must not blank the row
# `t` hands back the raw template when given no params, so a leftover
# placeholder means the key and the params disagree — show the
# English message rather than "{name} 从 iCloud 下载…".
if "{" in text and row.error:
return row.error
return text

def retry_row(self, row_id: int) -> None:
if self.vm.start_batch(only_ids=[row_id]):
self.sync_buttons(running=True)
Expand Down
5 changes: 5 additions & 0 deletions src/scripto/gui_qt/settings_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ def _build(self) -> None:
overwrite = QCheckBox(t("gui.settings_overwrite"))
overwrite.setChecked(bool(config["overwrite"]))
overwrite.toggled.connect(lambda on: self._save("overwrite", bool(on)))
evict = QCheckBox(t("gui.settings_icloud_evict"))
evict.setToolTip(t("gui.settings_icloud_evict_hint"))
evict.setChecked(bool(config["icloud_evict"]))
evict.toggled.connect(lambda on: self._save("icloud_evict", bool(on)))

export = QLineEdit(str(config.get("export_dir") or ""))
export.setPlaceholderText(t("gui.settings_export"))
Expand Down Expand Up @@ -118,6 +122,7 @@ def _build(self) -> None:
form.addRow(t("gui.settings_ollama_model"), ollama)
form.addRow("", recursive)
form.addRow("", overwrite)
form.addRow("", evict)
form.addRow(t("gui.settings_export"), export)

content = QWidget()
Expand Down
6 changes: 5 additions & 1 deletion src/scripto/i18n/en.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"errors.no_engine": "No transcription engine is available. Run `uv sync` to install dependencies.",
"errors.file_missing": "File not found: {name}",
"errors.file_empty": "File is empty: {name}",
"errors.icloud_placeholder": "{name} looks like an iCloud placeholder that is not downloaded locally. In Finder, right-click it and choose \"Download Now\".",
"errors.icloud_timeout": "{name} is still downloading from iCloud after {seconds}s. Check your connection, or download it in Finder first.",
"errors.icloud_failed": "Could not download {name} from iCloud: {reason}. In Finder, right-click it and choose \"Download Now\".",
"errors.tcc_denied": "macOS denied access to {name}. Grant your terminal/app access in System Settings → Privacy & Security → Files and Folders, or move the file out of Desktop/Documents/Downloads.",
"errors.file_permission": "Permission denied reading {name}.",
"errors.file_unreadable": "Cannot read {name}: {reason}",
Expand All @@ -42,6 +43,7 @@
"scan.unsupported": "unsupported file type, skipped: {value}",
"scan.empty_dir": "no supported media in folder: {value}",
"status.pending": "waiting",
"status.downloading": "downloading from iCloud",
"status.extracting": "extracting audio",
"status.transcribing": "transcribing",
"status.translating": "translating",
Expand Down Expand Up @@ -95,6 +97,8 @@
"gui.settings_tlang": "Transcription language",
"gui.settings_recursive": "Scan folders recursively",
"gui.settings_overwrite": "Overwrite existing outputs",
"gui.settings_icloud_evict": "Return iCloud files to the cloud after processing",
"gui.settings_icloud_evict_hint": "Files that are not downloaded yet are fetched automatically, then freed from local disk again once their audio has been extracted. Files already on your disk are never touched.",
"gui.settings_translate": "Translate subtitles (Ollama)",
"gui.settings_target": "Translation target",
"gui.settings_ollama_model": "Ollama model",
Expand Down
6 changes: 5 additions & 1 deletion src/scripto/i18n/zh.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"errors.no_engine": "没有可用的转录引擎。请运行 `uv sync` 安装依赖。",
"errors.file_missing": "文件不存在:{name}",
"errors.file_empty": "文件为空:{name}",
"errors.icloud_placeholder": "{name} 疑似 iCloud 占位文件,尚未下载到本地。请在 Finder 中右键选择「立即下载」。",
"errors.icloud_timeout": "{name} 从 iCloud 下载已超过 {seconds} 秒仍未完成。请检查网络,或先在 Finder 中下载。",
"errors.icloud_failed": "无法从 iCloud 下载 {name}:{reason}。请在 Finder 中右键选择「立即下载」。",
"errors.tcc_denied": "macOS 拒绝访问 {name}。请在 系统设置 → 隐私与安全性 → 文件和文件夹 中给你的终端/应用授权,或把文件移出 桌面/文稿/下载 文件夹。",
"errors.file_permission": "读取 {name} 时权限被拒绝。",
"errors.file_unreadable": "无法读取 {name}:{reason}",
Expand All @@ -42,6 +43,7 @@
"scan.unsupported": "不支持的文件类型,已跳过:{value}",
"scan.empty_dir": "文件夹内无支持的媒体:{value}",
"status.pending": "等待中",
"status.downloading": "从 iCloud 下载中",
"status.extracting": "提取音频",
"status.transcribing": "转录中",
"status.translating": "翻译中",
Expand Down Expand Up @@ -95,6 +97,8 @@
"gui.settings_tlang": "转录语言",
"gui.settings_recursive": "递归扫描文件夹",
"gui.settings_overwrite": "覆盖已有输出",
"gui.settings_icloud_evict": "处理完把 iCloud 文件放回云端",
"gui.settings_icloud_evict_hint": "未下载的文件会自动下载,提取完音频后再从本地释放,恢复成仅云端存储。已经在本地的文件不会被动到。",
"gui.settings_translate": "翻译字幕(Ollama)",
"gui.settings_target": "翻译目标语言",
"gui.settings_ollama_model": "Ollama 模型",
Expand Down
Loading
Loading