diff --git a/src/scripto/cli.py b/src/scripto/cli.py index 77e02e8..447f7da 100644 --- a/src/scripto/cli.py +++ b/src/scripto/cli.py @@ -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"]), diff --git a/src/scripto/core/config.py b/src/scripto/core/config.py index f230641..bb36287 100644 --- a/src/scripto/core/config.py +++ b/src/scripto/core/config.py @@ -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 diff --git a/src/scripto/core/events.py b/src/scripto/core/events.py index aacc3f6..209d161 100644 --- a/src/scripto/core/events.py +++ b/src/scripto/core/events.py @@ -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) diff --git a/src/scripto/core/jobs.py b/src/scripto/core/jobs.py index 814ab94..aaca915 100644 --- a/src/scripto/core/jobs.py +++ b/src/scripto/core/jobs.py @@ -9,6 +9,7 @@ class JobStatus(StrEnum): PENDING = "pending" + DOWNLOADING = "downloading" # pulling an iCloud file onto local disk EXTRACTING = "extracting" TRANSCRIBING = "transcribing" TRANSLATING = "translating" @@ -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) diff --git a/src/scripto/core/pipeline.py b/src/scripto/core/pipeline.py index 39c4fca..8b4d927 100644 --- a/src/scripto/core/pipeline.py +++ b/src/scripto/core/pipeline.py @@ -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 @@ -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, @@ -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 @@ -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: @@ -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) @@ -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, + ) ) diff --git a/src/scripto/gui/viewmodel.py b/src/scripto/gui/viewmodel.py index 801a57a..d09bce5 100644 --- a/src/scripto/gui/viewmodel.py +++ b/src/scripto/gui/viewmodel.py @@ -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" | "" @@ -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)), @@ -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 ): diff --git a/src/scripto/gui_qt/run_page.py b/src/scripto/gui_qt/run_page.py index 2391638..8be2152 100644 --- a/src/scripto/gui_qt/run_page.py +++ b/src/scripto/gui_qt/run_page.py @@ -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", @@ -48,6 +49,7 @@ } ACTIVE_STATUSES = ( + JobStatus.DOWNLOADING.value, JobStatus.EXTRACTING.value, JobStatus.TRANSCRIBING.value, JobStatus.TRANSLATING.value, @@ -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) @@ -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) diff --git a/src/scripto/gui_qt/settings_page.py b/src/scripto/gui_qt/settings_page.py index e0446da..70fa17f 100644 --- a/src/scripto/gui_qt/settings_page.py +++ b/src/scripto/gui_qt/settings_page.py @@ -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")) @@ -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() diff --git a/src/scripto/i18n/en.py b/src/scripto/i18n/en.py index 7ae9b39..cb8f662 100644 --- a/src/scripto/i18n/en.py +++ b/src/scripto/i18n/en.py @@ -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}", @@ -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", @@ -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", diff --git a/src/scripto/i18n/zh.py b/src/scripto/i18n/zh.py index cdfb7f1..06f8dfb 100644 --- a/src/scripto/i18n/zh.py +++ b/src/scripto/i18n/zh.py @@ -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}", @@ -42,6 +43,7 @@ "scan.unsupported": "不支持的文件类型,已跳过:{value}", "scan.empty_dir": "文件夹内无支持的媒体:{value}", "status.pending": "等待中", + "status.downloading": "从 iCloud 下载中", "status.extracting": "提取音频", "status.transcribing": "转录中", "status.translating": "翻译中", @@ -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 模型", diff --git a/src/scripto/media/access.py b/src/scripto/media/access.py index a804e8c..dbd69b6 100644 --- a/src/scripto/media/access.py +++ b/src/scripto/media/access.py @@ -1,38 +1,218 @@ -"""Pre-flight readability diagnosis for source media. +"""Pre-flight readability diagnosis for source media, and iCloud downloads. "ffmpeg: Operation not permitted" tells the user nothing. Before extraction we probe the file ourselves and raise a ScriptoError that distinguishes: - macOS TCC privacy denial (terminal/app lacks Desktop/Documents/Downloads folder permission) → point at System Settings -- iCloud placeholder (file not downloaded locally: size > 0, zero blocks - on disk) → point at Finder's "Download Now" - anything else unreadable → plain readable reason + +iCloud files are not an error at all. macOS keeps them *dataless*: the name, +size and timestamps are real, the bytes live in the cloud, and the kernel +fetches them from the file provider the moment anything reads. So a file +that isn't downloaded yet is something we can fix ourselves — ``materialize`` +pulls it down instead of sending the user to Finder to right-click dozens of +lecture recordings one at a time. + +Detection is the ``UF_DATALESS`` stat flag, not ``st_blocks == 0``: an +APFS-compressed file that is fully present on disk also reports zero blocks, +and refusing those was a false positive. """ from __future__ import annotations import os import sys +import threading +import time from pathlib import Path -from ..core.errors import ScriptoError +from ..core.errors import OperationStopped, ScriptoError _TCC_PROTECTED_PARTS = {"Desktop", "Documents", "Downloads"} _PROBE_BYTES = 4096 +# macOS: the file's contents are not on this disk (iCloud Drive, and any +# other File Provider). Defined in . +UF_DATALESS = 0x40000000 + +_BLOCK_BYTES = 512 +_POLL_SEC = 0.25 +# A download gets this long per megabyte, on top of a fixed floor — generous +# enough for a lecture recording on hotel wifi, short enough that an offline +# machine reports something instead of hanging until the user gives up. +_TIMEOUT_FLOOR_SEC = 120.0 +_TIMEOUT_PER_MB_SEC = 12.0 + def _is_tcc_suspect(path: Path) -> bool: return sys.platform == "darwin" and bool(_TCC_PROTECTED_PARTS & set(path.parts)) -def _is_icloud_placeholder(st: os.stat_result) -> bool: - blocks = getattr(st, "st_blocks", None) - return blocks == 0 and st.st_size > 0 +def _is_not_downloaded(st: os.stat_result) -> bool: + """True when the file's bytes are in the cloud rather than on this disk.""" + if hasattr(st, "st_flags"): + return bool(st.st_flags & UF_DATALESS) + # No stat flags (non-BSD): fall back to the blocks heuristic, which is + # all that platform gives us. + return getattr(st, "st_blocks", None) == 0 and st.st_size > 0 + + +def needs_download(src: Path) -> bool: + try: + return _is_not_downloaded(src.stat()) + except OSError: + return False + + +def download_timeout(size_bytes: int) -> float: + return _TIMEOUT_FLOOR_SEC + (size_bytes / 1_000_000) * _TIMEOUT_PER_MB_SEC + + +def materialize( + src: Path, + *, + stop_check=None, + on_progress=None, + timeout_sec: float | None = None, +) -> None: + """Pull a not-yet-downloaded iCloud file onto local disk. + + Reading is the trigger — one byte is enough to bring the whole file + down — but that read blocks for the entire download, minutes for a + lecture recording. So it runs on a thread while we watch the file grow, + stay responsive to a stop request, and give up on a size-scaled + deadline rather than hanging forever when there is no network. + + ``on_progress(done_bytes, total_bytes)`` is best-effort: some providers + only publish the blocks at the end, in which case it reports 0 until + the download completes. + """ + try: + st = src.stat() + except OSError: + return + if not _is_not_downloaded(st): + return + + total = st.st_size + limit = timeout_sec or download_timeout(total) + deadline = time.monotonic() + limit + failure: list[BaseException] = [] + + def pull() -> None: + try: + with src.open("rb") as handle: + handle.read(1) + except BaseException as exc: # re-raised on the caller's thread + failure.append(exc) + + # Daemon: if we abandon it on stop or timeout, the read is still parked + # in the kernel and must not keep the process alive. + worker = threading.Thread(target=pull, name="scripto-icloud", daemon=True) + worker.start() + + while True: + worker.join(_POLL_SEC) + if not worker.is_alive(): + break + if stop_check is not None and stop_check(): + raise OperationStopped() + if on_progress is not None: + on_progress(_downloaded_bytes(src, total), total) + if time.monotonic() > deadline: + raise ScriptoError( + f"{src.name} is still downloading from iCloud after " + f"{int(limit)}s. Check your connection, or download it in " + "Finder first.", + key="errors.icloud_timeout", + name=src.name, + seconds=int(limit), + ) + + if failure: + exc = failure[0] + if isinstance(exc, PermissionError): + _raise_permission(src) + raise ScriptoError( + f"Could not download {src.name} from iCloud: {exc}. " + "In Finder, right-click it and choose \"Download Now\".", + key="errors.icloud_failed", + name=src.name, + reason=str(exc), + ) from None + if on_progress is not None: + on_progress(total, total) + + +def evict(src: Path) -> bool: + """Send a downloaded iCloud file back to cloud-only storage. + + The counterpart to ``materialize`` — Finder's "Remove Download". Called + for files we pulled down ourselves, so a batch of lecture recordings + doesn't leave tens of gigabytes behind: the file is left exactly as we + found it, name and size intact, bytes back in the cloud. + + ``brctl evict`` was removed from macOS, so the only supported route is + NSFileManager's, reached here through the Objective-C runtime rather + than by taking a PyObjC dependency for a single selector. Returns False + instead of raising — failing to tidy up is never worth failing a job. + """ + if sys.platform != "darwin": + return False + try: + import ctypes + import ctypes.util + + objc = ctypes.cdll.LoadLibrary(ctypes.util.find_library("objc")) + objc.objc_getClass.restype = ctypes.c_void_p + objc.objc_getClass.argtypes = [ctypes.c_char_p] + objc.sel_registerName.restype = ctypes.c_void_p + objc.sel_registerName.argtypes = [ctypes.c_char_p] + + def send(restype, obj, selector, argtypes=(), *args): + # objc_msgSend is variadic: its signature must be declared per + # call site, not once. + fn = objc["objc_msgSend"] + fn.restype = restype + fn.argtypes = [ctypes.c_void_p, ctypes.c_void_p, *argtypes] + return fn(obj, objc.sel_registerName(selector), *args) + + text = send( + ctypes.c_void_p, objc.objc_getClass(b"NSString"), + b"stringWithUTF8String:", [ctypes.c_char_p], str(src).encode(), + ) + url = send( + ctypes.c_void_p, objc.objc_getClass(b"NSURL"), + b"fileURLWithPath:", [ctypes.c_void_p], text, + ) + manager = send( + ctypes.c_void_p, objc.objc_getClass(b"NSFileManager"), b"defaultManager" + ) + error = ctypes.c_void_p(0) + return bool(send( + ctypes.c_bool, manager, b"evictUbiquitousItemAtURL:error:", + [ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p)], + url, ctypes.byref(error), + )) + except Exception: + return False + + +def _downloaded_bytes(src: Path, total: int) -> int: + try: + blocks = getattr(src.stat(), "st_blocks", 0) or 0 + except OSError: + return 0 + return min(total, blocks * _BLOCK_BYTES) def check_readable(src: Path) -> None: - """Raise a diagnosis-specific ScriptoError when ``src`` can't be processed.""" + """Raise a diagnosis-specific ScriptoError when ``src`` can't be processed. + + A file that is merely not downloaded yet is not a failure — see + ``materialize``, which the caller runs first. + """ try: st = src.stat() except FileNotFoundError: @@ -47,13 +227,6 @@ def check_readable(src: Path) -> None: raise ScriptoError( f"File is empty: {src.name}", key="errors.file_empty", name=src.name ) - if _is_icloud_placeholder(st): - raise ScriptoError( - f"{src.name} looks like an iCloud placeholder that is not downloaded " - "locally. In Finder, right-click it and choose \"Download Now\".", - key="errors.icloud_placeholder", - name=src.name, - ) try: with src.open("rb") as f: diff --git a/tests/test_access.py b/tests/test_access.py index 580fb01..79e67a8 100644 --- a/tests/test_access.py +++ b/tests/test_access.py @@ -1,11 +1,24 @@ import os import sys +import time import pytest -from scripto.core.errors import ScriptoError +from scripto.core.errors import OperationStopped, ScriptoError from scripto.media import access +UF_COMPRESSED = 0x00000020 + + +class FakeStat: + """Stand-in for os.stat_result carrying only the fields access.py reads.""" + + def __init__(self, size: int, *, flags: int = 0, blocks: int = 8): + self.st_size = size + self.st_flags = flags + self.st_blocks = blocks + self.st_mode = 0o100644 + def _key_of(excinfo) -> str: return excinfo.value.key @@ -31,20 +44,106 @@ def test_empty_file(tmp_path): assert _key_of(excinfo) == "errors.file_empty" -def test_icloud_placeholder_detected(tmp_path, monkeypatch): +def _fake_stat(monkeypatch, stat: FakeStat) -> None: + monkeypatch.setattr(access.Path, "stat", lambda self: stat) + + +def test_dataless_flag_marks_a_file_as_not_downloaded(tmp_path, monkeypatch): + f = tmp_path / "cloud.mp4" + f.write_bytes(b"data") + _fake_stat(monkeypatch, FakeStat(999, flags=access.UF_DATALESS, blocks=0)) + assert access.needs_download(f) + + +def test_compressed_local_file_is_not_mistaken_for_a_cloud_file(tmp_path, monkeypatch): + """APFS-compressed files also report zero blocks — the old false positive.""" + f = tmp_path / "local.mp4" + f.write_bytes(b"data") + _fake_stat(monkeypatch, FakeStat(999, flags=UF_COMPRESSED, blocks=0)) + assert not access.needs_download(f) + + +def test_check_readable_lets_a_cloud_file_through(tmp_path, monkeypatch): + """Not downloaded is a download to do, not a failure to report.""" f = tmp_path / "cloud.mp4" f.write_bytes(b"data") - real = f.stat() + _fake_stat(monkeypatch, FakeStat(4, flags=access.UF_DATALESS, blocks=0)) + access.check_readable(f) # must not raise + + +def test_materialize_pulls_the_file_down_by_reading_it(tmp_path, monkeypatch): + f = tmp_path / "cloud.mp4" + f.write_bytes(b"x" * 64) + opened: list = [] + real_open = access.Path.open + + def spy_open(self, *args, **kwargs): + opened.append(self) + return real_open(self, *args, **kwargs) - class FakeStat: - st_size = real.st_size - st_blocks = 0 - st_mode = real.st_mode + _fake_stat(monkeypatch, FakeStat(64, flags=access.UF_DATALESS, blocks=0)) + monkeypatch.setattr(access.Path, "open", spy_open) - monkeypatch.setattr(access.Path, "stat", lambda self: FakeStat()) + seen: list[tuple[int, int]] = [] + access.materialize(f, on_progress=lambda done, total: seen.append((done, total))) + assert opened == [f] # reading is what triggers the download + assert seen[-1] == (64, 64) # and it finishes reported as complete + + +def test_materialize_skips_a_file_already_on_disk(tmp_path, monkeypatch): + f = tmp_path / "local.mp4" + f.write_bytes(b"x" * 64) + opened: list = [] + monkeypatch.setattr( + access.Path, "open", lambda self, *a, **k: opened.append(self) + ) + _fake_stat(monkeypatch, FakeStat(64, flags=0, blocks=8)) + access.materialize(f) + assert opened == [] + + +def test_materialize_times_out_instead_of_hanging(tmp_path, monkeypatch): + f = tmp_path / "cloud.mp4" + f.write_bytes(b"x") + _fake_stat(monkeypatch, FakeStat(1, flags=access.UF_DATALESS, blocks=0)) + monkeypatch.setattr( + access.Path, "open", lambda self, *a, **k: time.sleep(1.0) + ) with pytest.raises(ScriptoError) as excinfo: - access.check_readable(f) - assert _key_of(excinfo) == "errors.icloud_placeholder" + access.materialize(f, timeout_sec=0.1) + assert _key_of(excinfo) == "errors.icloud_timeout" + + +def test_materialize_honours_a_stop_request(tmp_path, monkeypatch): + f = tmp_path / "cloud.mp4" + f.write_bytes(b"x") + _fake_stat(monkeypatch, FakeStat(1, flags=access.UF_DATALESS, blocks=0)) + monkeypatch.setattr( + access.Path, "open", lambda self, *a, **k: time.sleep(1.0) + ) + with pytest.raises(OperationStopped): + access.materialize(f, stop_check=lambda: True) + + +def test_materialize_reports_a_failed_download(tmp_path, monkeypatch): + f = tmp_path / "cloud.mp4" + f.write_bytes(b"x") + _fake_stat(monkeypatch, FakeStat(1, flags=access.UF_DATALESS, blocks=0)) + + def offline(self, *args, **kwargs): + raise OSError("network is down") + + monkeypatch.setattr(access.Path, "open", offline) + with pytest.raises(ScriptoError) as excinfo: + access.materialize(f) + assert _key_of(excinfo) == "errors.icloud_failed" + + +def test_evict_never_raises_on_an_ordinary_file(tmp_path): + f = tmp_path / "local.mp4" + f.write_bytes(b"data") + assert access.evict(f) is False # not an iCloud item: nothing to do + assert f.exists() @pytest.mark.skipif(os.geteuid() == 0, reason="root ignores file modes") diff --git a/tests/test_gui_qt.py b/tests/test_gui_qt.py index 358dad8..672310b 100644 --- a/tests/test_gui_qt.py +++ b/tests/test_gui_qt.py @@ -231,3 +231,34 @@ def test_history_page_shows_translation_queue_status(tmp_path, qapp): page.tick_translations() assert page._seen_terminal == 1 # toast fired exactly once assert not page.tq_strip.isVisibleTo(page) + + +def test_failure_text_is_localized_when_core_supplied_a_key(tmp_path, qapp): + """Core raises English + an i18n key; the row must show the user's language.""" + from scripto.gui.viewmodel import FileRow + + window = make_window(tmp_path, qapp) + window.vm.update_settings(language="zh") + page = window.run_page + + row = FileRow( + id=1, + path=tmp_path / "clip.mp4", + status="failed", + error="clip.mp4 is still downloading from iCloud after 600s.", + error_key="errors.icloud_timeout", + error_params=(("name", "clip.mp4"), ("seconds", "600")), + ) + text = page.error_text(row) + assert "iCloud" in text and "600" in text + assert "still downloading" not in text # not the English fallback + + # No key (a raw ffmpeg message): the English text shows through unchanged. + plain = FileRow(id=2, path=tmp_path / "clip.mp4", status="failed", + error="extract: ffmpeg exploded") + assert page.error_text(plain) == "extract: ffmpeg exploded" + + # A params/template mismatch must not blank the row. + broken = FileRow(id=3, path=tmp_path / "clip.mp4", status="failed", + error="fallback text", error_key="errors.icloud_timeout") + assert page.error_text(broken) == "fallback text" diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 435b301..b5ee594 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -190,6 +190,83 @@ def test_failed_job_does_not_claim_unrelated_subtitles(tmp_path, fake_extract): assert entry.status == "failed" and entry.outputs == [] +@pytest.fixture +def fake_icloud(monkeypatch): + """A source that is in the cloud until something materializes it.""" + state = {"in_cloud": set(), "downloaded": [], "evicted": []} + + def needs_download(src): + return src in state["in_cloud"] + + def materialize(src, *, stop_check=None, on_progress=None, timeout_sec=None): + if src not in state["in_cloud"]: + return + state["downloaded"].append(src) + state["in_cloud"].discard(src) + if on_progress is not None: + on_progress(1000, 1000) + + def evict(src): + state["evicted"].append(src) + state["in_cloud"].add(src) + return True + + monkeypatch.setattr(pl.access, "needs_download", needs_download) + monkeypatch.setattr(pl.access, "materialize", materialize) + monkeypatch.setattr(pl.access, "evict", evict) + return state + + +def test_cloud_file_is_downloaded_then_returned_to_the_cloud( + tmp_path, fake_extract, fake_icloud +): + (video,) = make_media(tmp_path, 1) + fake_icloud["in_cloud"].add(video) + + pipe, bus = make_pipeline(tmp_path) + seen: list[str] = [] + bus.subscribe(lambda e: seen.append(getattr(e, "status", ""))) + jobs, stats = pipe.run([video], threading.Event()) + + assert stats.done == 1 and jobs[0].status == JobStatus.DONE + assert fake_icloud["downloaded"] == [video] # pulled down to work on + assert fake_icloud["evicted"] == [video] # and put back afterwards + assert JobStatus.DOWNLOADING.value in seen # the UI can show it happening + + +def test_local_file_is_never_evicted(tmp_path, fake_extract, fake_icloud): + """Only files this run downloaded — never ones the user already had.""" + (video,) = make_media(tmp_path, 1) + pipe, _bus = make_pipeline(tmp_path) + pipe.run([video], threading.Event()) + assert fake_icloud["downloaded"] == [] and fake_icloud["evicted"] == [] + + +def test_cloud_file_stays_local_when_eviction_is_off( + tmp_path, fake_extract, fake_icloud +): + (video,) = make_media(tmp_path, 1) + fake_icloud["in_cloud"].add(video) + pipe, _bus = make_pipeline(tmp_path) + pipe._s.icloud_evict = False + pipe.run([video], threading.Event()) + assert fake_icloud["downloaded"] == [video] + assert fake_icloud["evicted"] == [] + + +def test_cloud_file_is_returned_even_when_the_job_fails( + tmp_path, fake_extract, fake_icloud +): + (video,) = make_media(tmp_path, 1) + fake_icloud["in_cloud"].add(video) + fake_extract["fail_on"] = {video.stem} + + pipe, _bus = make_pipeline(tmp_path) + _jobs, stats = pipe.run([video], threading.Event()) + assert stats.failed == 1 + assert fake_icloud["evicted"] == [video] # no downloads left behind + + def test_overwrite_regenerates(tmp_path, fake_extract): files = make_media(tmp_path, 1) target = files[0].parent / (files[0].stem + ".en.srt")