Source code for remote_compression.progress

"""Console output: logging, and tqdm bars fed by the ffmpeg ``-progress`` stream."""

import logging
import os
import sys
import time
from datetime import datetime
from pathlib import Path

from platformdirs import user_data_dir
from tqdm import tqdm

logger = logging.getLogger("rcomp")

#: Age (days) beyond which per-run log files are purged at startup.
LOG_RETENTION_DAYS = 30


[docs] def logs_dir(): """ Returns ------- :class:`~pathlib.Path` Directory of the per-run log files. """ return Path(user_data_dir(appname="rcomp", appauthor=False)) / "logs"
def _purge_old_logs(directory): """Best-effort removal of run logs older than :data:`LOG_RETENTION_DAYS`.""" cutoff = time.time() - LOG_RETENTION_DAYS * 86400 for old in directory.glob("rcomp-*.log"): try: if old.stat().st_mtime < cutoff: old.unlink() except OSError: continue
[docs] def human_size(n): """ Parameters ---------- n: :class:`int` Byte count. Returns ------- :class:`str` Human-readable size. Examples -------- >>> human_size(0) '0 B' >>> human_size(2048) '2.0 KB' >>> human_size(3 * 1024**3) '3.0 GB' """ size = float(n) for unit in ("B", "KB", "MB", "GB", "TB"): if abs(size) < 1024 or unit == "TB": return f"{size:.1f} {unit}" if unit != "B" else f"{int(size)} B" size /= 1024 return None # pragma: no cover - unreachable
[docs] class TqdmHandler(logging.Handler): """Logging handler routed through :func:`tqdm.tqdm.write` so bars stay intact."""
[docs] def emit(self, record): try: tqdm.write(self.format(record), file=sys.stderr) except Exception: # noqa: BLE001 # pragma: no cover self.handleError(record)
[docs] def setup_logging(verbosity=0): """ Configure the ``rcomp`` logger: console handler at the requested verbosity, plus a DEBUG file handler on a fresh per-run file (parallel rcomp processes never share a log file, so there is no rotation and no locking). Parameters ---------- verbosity: :class:`int` Negative for quiet (warnings only), 0 for normal (one line per file), positive for verbose (full commands, ffmpeg stderr). """ if verbosity > 0: level, fmt = logging.DEBUG, "%(levelname)s %(message)s" elif verbosity == 0: level, fmt = logging.INFO, "%(message)s" else: level, fmt = logging.WARNING, "%(message)s" for handler in logger.handlers: handler.close() logger.handlers.clear() logger.setLevel(logging.DEBUG) # the file handler always gets DEBUG logger.propagate = False console = TqdmHandler() console.setLevel(level) console.setFormatter(logging.Formatter(fmt)) logger.addHandler(console) try: directory = logs_dir() directory.mkdir(parents=True, exist_ok=True) _purge_old_logs(directory) stamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S") file_handler = logging.FileHandler( directory / f"rcomp-{stamp}-{os.getpid()}.log", encoding="utf-8" ) file_handler.setLevel(logging.DEBUG) file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s")) logger.addHandler(file_handler) logger.debug("Logging to %s", file_handler.baseFilename) except OSError: pass # a full or read-only disk must not break the CLI
[docs] def parse_progress_line(line): """ Parse one line of ffmpeg ``-progress`` output. Parameters ---------- line: :class:`str` A ``key=value`` line. Returns ------- :class:`tuple` or None ``(key, value)``, or None for anything else. Examples -------- >>> parse_progress_line('out_time_us=4520000') ('out_time_us', '4520000') >>> parse_progress_line('speed=1.19x') ('speed', '1.19x') >>> parse_progress_line('not a progress line') is None True """ line = line.strip() if not line or "=" not in line: return None key, _, value = line.partition("=") return key.strip(), value.strip()
[docs] class ProgressStream: """ Incremental parser of the ffmpeg ``-progress`` stream. Feed it arbitrary text chunks (they may split lines anywhere); it tracks the encoded position in seconds and the encoding speed, and invokes `callback` at the end of each progress block (the ``progress=`` line, ~2 per second). Parameters ---------- callback: callable, optional Called with the stream itself after each progress block. Attributes ---------- seconds: :class:`float` Current output position, in seconds (never negative). speed: :class:`float`, optional Encoding speed factor (e.g. ``1.19``), when known. """ def __init__(self, callback=None): self.callback = callback self.seconds = 0.0 self.speed = None self._buffer = ""
[docs] def feed(self, chunk): """Consume one decoded chunk of ``-progress`` output.""" self._buffer += chunk *lines, self._buffer = self._buffer.split("\n") for line in lines: parsed = parse_progress_line(line) if parsed is None: continue key, value = parsed # ffmpeg quirk: out_time_ms is also in microseconds, same as out_time_us. if key in {"out_time_us", "out_time_ms"}: try: self.seconds = max(0.0, int(value) / 1e6) except ValueError: pass elif key == "speed": try: self.speed = float(value.rstrip("x")) except ValueError: self.speed = None elif key == "progress" and self.callback is not None: self.callback(self)
__call__ = feed
[docs] class FileBar: """ Per-file encode bar: seconds encoded out of the file duration. Parameters ---------- name: :class:`str` File name (bar label). duration: :class:`float`, optional Total duration in seconds; without it the bar shows raw progress. enabled: :class:`bool` Draw the bar (disable on non-TTY or ``--no-progress``). """ def __init__(self, name, duration=None, enabled=True): # Integer seconds throughout: tqdm accumulates n by summing updates, # and summing rounded floats drifts into '198.40000000000003' displays. self.bar = tqdm( total=max(1, round(duration)) if duration else None, desc=name if len(name) <= 40 else name[:37] + "...", unit="s", position=1, leave=False, disable=not enabled, ) self.stream = ProgressStream(callback=self._refresh) def _refresh(self, stream): position = int(stream.seconds) if self.bar.total is not None: position = min(position, self.bar.total) delta = position - self.bar.n if delta > 0: self.bar.update(delta) if stream.speed is not None: self.bar.set_postfix_str(f"{stream.speed:.2f}x", refresh=False) def __call__(self, chunk): self.stream.feed(chunk) def close(self): self.bar.close()
[docs] class BatchBar: """ Whole-batch bar: one tick per file, with status counters as postfix. Parameters ---------- total: :class:`int` Number of files in the batch. enabled: :class:`bool` Draw the bar. """ def __init__(self, total, enabled=True): self.bar = tqdm(total=total, desc="batch", unit="file", position=0, disable=not enabled)
[docs] def advance(self, summary): """Tick one file and refresh the counters from a BatchSummary.""" counters = [] for label in ("done", "conform", "tracked", "not_profitable", "failed", "dry_run"): value = getattr(summary, label) if value: counters.append(f"{label}={value}") self.bar.set_postfix_str(" ".join(counters), refresh=False) self.bar.update(1)
def close(self): self.bar.close()
[docs] def bars_enabled(no_progress=False): """ Returns ------- :class:`bool` Whether progress bars should be drawn: not explicitly disabled, and stderr is a real terminal. """ return not no_progress and sys.stderr.isatty()