"""Runs ffmpeg for each queued job, one at a time, via QProcess.""" from __future__ import annotations import os from pathlib import Path from PySide6.QtCore import QObject, QProcess, Signal from ..command import Options, build_command, format_command, plan_output_path, temp_output_path from ..tools import FFmpegTools from .queue_model import Job, Status def _remove_quietly(path: Path | None) -> None: if path is None: return try: path.unlink() except OSError: pass class BatchRunner(QObject): job_changed = Signal(object) # Job log = Signal(str) progress = Signal(float) # overall 0..1 finished = Signal(dict) # counts per status def __init__(self, parent=None): super().__init__(parent) self._proc: QProcess | None = None self._jobs: list[Job] = [] self._pos = 0 self._opts = Options() self._tools: FFmpegTools | None = None self._taken: set[str] = set() self._current: Job | None = None self._temp: Path | None = None self._stdout_buf = b"" self._stderr_buf = b"" self._stderr_tail: list[str] = [] self._cancelled = False @property def running(self) -> bool: return self._current is not None # -- public API --------------------------------------------------------- def start(self, jobs: list[Job], opts: Options, tools: FFmpegTools) -> None: self._jobs, self._opts, self._tools = jobs, opts, tools self._pos, self._taken, self._cancelled = 0, set(), False for job in jobs: job.status, job.progress, job.message, job.output = Status.QUEUED, 0.0, "", None self.job_changed.emit(job) self._next() def cancel(self) -> None: self._cancelled = True if self._proc is not None and self._proc.state() != QProcess.NotRunning: self.log.emit("Stopping...") self._proc.kill() elif self._current is None: self._finish_batch() def kill_now(self) -> None: """Used on application exit: stop ffmpeg and remove the partial file.""" self._cancelled = True if self._proc is not None and self._proc.state() != QProcess.NotRunning: self._proc.kill() self._proc.waitForFinished(3000) _remove_quietly(self._temp) # -- internals ---------------------------------------------------------- def _emit_overall(self, current_fraction: float = 0.0) -> None: if self._jobs: self.progress.emit(min(1.0, (self._pos + current_fraction) / len(self._jobs))) def _next(self) -> None: while self._pos < len(self._jobs): if self._cancelled: break job = self._jobs[self._pos] if self._launch(job): return self._pos += 1 self._emit_overall() self._finish_batch() def _launch(self, job: Job) -> bool: """Start ffmpeg for ``job``. Returns False if the job finished immediately.""" opts = self._opts out = plan_output_path(job.path, opts, self._taken) if out is None: job.status, job.message = Status.SKIPPED, "output already exists" self.log.emit(f"Skipping {job.path.name}: output already exists") self.job_changed.emit(job) return False try: out.parent.mkdir(parents=True, exist_ok=True) except OSError as e: job.status, job.message = Status.FAILED, f"cannot create output folder: {e}" self.log.emit(f"ERROR {job.path.name}: {job.message}") self.job_changed.emit(job) return False self._temp = temp_output_path(out) built = build_command(self._tools.ffmpeg, job.info, opts, job.selected_audio, self._temp) job.output = out self._taken.add(os.path.normcase(os.path.abspath(out))) self.log.emit("") self.log.emit(f"=== [{self._pos + 1}/{len(self._jobs)}] {job.path}") self.log.emit(f" -> {out}") for note in built.notes: self.log.emit(f" {note}") self.log.emit(format_command(built.args)) self._current = job self._stdout_buf = self._stderr_buf = b"" self._stderr_tail = [] job.status, job.progress, job.message = Status.RUNNING, 0.0, "" self.job_changed.emit(job) proc = QProcess(self) proc.readyReadStandardOutput.connect(self._on_stdout) proc.readyReadStandardError.connect(self._on_stderr) proc.finished.connect(self._on_finished) proc.errorOccurred.connect(self._on_error) self._proc = proc proc.start(built.args[0], built.args[1:]) return True def _on_stdout(self) -> None: self._stdout_buf += bytes(self._proc.readAllStandardOutput()) *lines, self._stdout_buf = self._stdout_buf.split(b"\n") job = self._current if job is None: return duration = job.info.duration if job.info else None speed = None updated = False for raw in lines: key, _, value = raw.decode(errors="replace").strip().partition("=") if key == "out_time_us" and duration: try: job.progress = max(0.0, min(1.0, int(value) / 1e6 / duration)) updated = True except ValueError: pass elif key == "speed" and value not in ("N/A", ""): speed = value if updated: job.message = f"{job.progress * 100:.0f}%" + (f" @ {speed}" if speed else "") self.job_changed.emit(job) self._emit_overall(job.progress) def _on_stderr(self) -> None: self._stderr_buf += bytes(self._proc.readAllStandardError()) *lines, self._stderr_buf = self._stderr_buf.split(b"\n") for raw in lines: line = raw.decode(errors="replace").rstrip() if line: self._stderr_tail = (self._stderr_tail + [line])[-5:] self.log.emit(f" ffmpeg: {line}") def _on_error(self, error) -> None: if error == QProcess.FailedToStart: # finished() is not emitted in this case. self._complete(ok=False, reason=f"could not start ffmpeg ({self._proc.errorString()})") def _on_finished(self, exit_code: int, exit_status) -> None: self._on_stderr() ok = exit_status == QProcess.NormalExit and exit_code == 0 reason = "" if ok else (self._stderr_tail[-1] if self._stderr_tail else f"ffmpeg exited with code {exit_code}") self._complete(ok, reason) def _complete(self, ok: bool, reason: str) -> None: job = self._current if job is None: return self._current = None if self._proc is not None: self._proc.deleteLater() self._proc = None if self._cancelled: job.status, job.message, job.progress = Status.CANCELLED, "", 0.0 _remove_quietly(self._temp) self.log.emit(f"Cancelled {job.path.name}") elif ok: try: os.replace(self._temp, job.output) job.status, job.progress, job.message = Status.DONE, 1.0, "" self.log.emit(f"Done: {job.output}") except OSError as e: job.status, job.message = Status.FAILED, f"could not rename output: {e}" _remove_quietly(self._temp) self.log.emit(f"ERROR {job.path.name}: {job.message}") else: job.status, job.message = Status.FAILED, reason _remove_quietly(self._temp) self.log.emit(f"FAILED {job.path.name}: {reason}") self._temp = None self.job_changed.emit(job) self._pos += 1 self._emit_overall() self._next() def _finish_batch(self) -> None: counts: dict[str, int] = {} for job in self._jobs: if job.status == Status.QUEUED: job.status = Status.READY self.job_changed.emit(job) counts[job.status.value] = counts.get(job.status.value, 0) + 1 self._jobs = [] self.finished.emit(counts)