"""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()