220 lines
8.1 KiB
Python
220 lines
8.1 KiB
Python
"""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)
|