#!/usr/bin/python3 # Copyright (C) 2026 Danilo M. # # This program is free software; you can redistribute it and/or modify it # under the terms of the GNU General Public License version 2 as published # by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General # Public License for more details. """fileops One local operation on files, for the assistant canvas's Do row. Prints one JSON line, {"ok": true, "out": [paths]} or {"ok": false, "error": "..."}. Each result is written under a temporary name and renamed into place, to the first free name; SIGTERM kills the running tool and removes what was half written. """ import ctypes import json import os import shutil import signal import subprocess import sys import tarfile import zipfile current = None # the running tool, killed with its group on SIGTERM temps = [] # partial results to remove if we do not finish ARCHIVE_EXTS = (".tar.gz", ".tar.xz", ".tar.bz2", ".tar.zst", ".tgz", ".txz", ".zip", ".tar", ".7z", ".rar", ".gz", ".xz", ".bz2", ".zst") class Fail(Exception): pass def finish(result, code): signal.signal(signal.SIGTERM, signal.SIG_IGN) # one answer: a late stop cannot add another print(json.dumps(result), flush=True) sys.exit(code) def stop(*_): signal.signal(signal.SIGTERM, signal.SIG_IGN) if current is not None: try: os.killpg(current.pid, signal.SIGKILL) except ProcessLookupError: pass current.wait() # gone before its folder is removed for t in temps: shutil.rmtree(t, ignore_errors=True) if os.path.isdir(t) else _unlink(t) finish({"ok": False, "error": "stopped"}, 1) def _unlink(path): try: os.unlink(path) except FileNotFoundError: pass def die_with_us(): # In the child: SIGKILL when fileops dies, however it dies. A stop is a # SIGTERM that fileops handles; a shell reload can be a SIGKILL it # cannot, and the tool, in its own session, would run on. ctypes.CDLL("libc.so.6").prctl(1, signal.SIGKILL) # PR_SET_PDEATHSIG def run(argv): # Own process group, so a stop takes ffmpeg and its children too. global current if shutil.which(argv[0]) is None: hint = ": link it into ~/bin" if argv[0] == "trackcrop" else "" raise Fail(f"{argv[0]} not found{hint}") # A stop between the fork and current being set would miss the child. signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGTERM}) try: current = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True, preexec_fn=die_with_us) finally: signal.pthread_sigmask(signal.SIG_UNBLOCK, {signal.SIGTERM}) out, err = current.communicate() rc, current = current.returncode, None if rc: raise Fail(f"{argv[0]}: {reason(argv[0], err, rc)}"[:300]) return out def reason(tool, err, rc): # The line that says what went wrong, without the tool's own prefix: # bsdtar ends on "Error exit delayed from previous errors", the cause # being earlier. lines = [l.strip() for l in err.splitlines() if l.strip()] lines = [l for l in lines if "Error exit delayed" not in l] or lines line = lines[-1] if lines else f"exit status {rc}" while line.startswith(tool + ":"): line = line[len(tool) + 1:].strip() return line def split(name): # ("report", ".pdf"), keeping a compound suffix whole: "a.tar.gz". low = name.lower() for ext in (".tar.gz", ".tar.xz", ".tar.bz2", ".tar.zst"): if low.endswith(ext): return name[:-len(ext)], name[-len(ext):] stem, ext = os.path.splitext(name) return stem, ext def stem(path): name = os.path.basename(path) for ext in ARCHIVE_EXTS: if name.lower().endswith(ext) and len(name) > len(ext): return name[:-len(ext)] return os.path.splitext(name)[0] def free(outdir, name, folder=False): # A folder's name is whole: "v1.2 notes (pages)" has no suffix to keep. path = os.path.join(outdir, name) base, ext = (name, "") if folder else split(name) n = 1 while os.path.lexists(path): path = os.path.join(outdir, f"{base} ({n}){ext}") n += 1 return path def make(outdir, name, build, folder=False): # build(tmp) writes the result at tmp; it keeps the suffix, which is how # ffmpeg and magick choose the format. tmp = os.path.join(outdir, f".fileops-{os.getpid()}-{len(temps)}{'' if folder else split(name)[1]}") temps.append(tmp) if folder: os.mkdir(tmp) build(tmp) final = free(outdir, name, folder) os.rename(tmp, final) temps.remove(tmp) return final def audio_codec(path): return run(["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name", "-of", "csv=p=0", path]).strip() # Copied as it is when the track already is one of these. COPY = {"aac": ".m4a", "mp3": ".mp3", "opus": ".opus"} GIF = "fps=12,scale=480:-1:flags=lanczos,split[a][b];[a]palettegen[p];[b][p]paletteuse" # This ffmpeg has no libx264 and no AAC encoder: H.264 is the GPU's, through # VAAPI, with MP3 audio, which MP4 carries. VAAPI = ["-vaapi_device", "/dev/dri/renderD128", "-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", "-qp", "23"] def each(op, outdir, f): s, ext = stem(f), os.path.splitext(f)[1] ff = ["ffmpeg", "-v", "error", "-y", "-i", f] if op == "pdf-images": return make(outdir, f"{s} (pages)", lambda t: run(["pdftoppm", "-r", "150", "-png", f, os.path.join(t, "page")]), folder=True) if op == "audio": codec = audio_codec(f) if not codec: raise Fail(f"{os.path.basename(f)} has no audio track") if codec in COPY: return make(outdir, s + COPY[codec], lambda t: run(ff + ["-vn", "-c:a", "copy", t])) return make(outdir, s + ".mp3", lambda t: run(ff + ["-vn", "-c:a", "libmp3lame", "-q:a", "2", t])) if op == "mp4": return make(outdir, s + ".mp4", lambda t: run(ff + VAAPI + ["-c:a", "libmp3lame", "-q:a", "2", "-movflags", "+faststart", t])) if op == "webm": return make(outdir, s + ".webm", lambda t: run(ff + ["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "33", "-c:a", "libopus", t])) if op == "gif": return make(outdir, s + ".gif", lambda t: run(ff + ["-vf", GIF, t])) if op == "trackcrop": return make(outdir, f"{s} (tracked).mp4", lambda t: run(["trackcrop", f, t])) if op in ("png", "jpeg", "webp"): # [0]: an animated GIF or WebP would come out as one file per frame, # under names the rename never finds. JPEG has no transparency, so # it is flattened onto white rather than left to turn black. flat = ["-background", "white", "-flatten"] if op == "jpeg" else [] src = f + "[0]" if op in ("png", "jpeg") else f return make(outdir, f"{s}.{'jpg' if op == 'jpeg' else op}", lambda t: run(["magick", src, *flat, t])) if op == "half": return make(outdir, f"{s} (50%){ext}", lambda t: run(["magick", f, "-resize", "50%", t])) if op == "extract": # A lone compressed file (note.txt.gz) is no archive to bsdtar -x. low = f.lower() if low.endswith((".gz", ".xz", ".bz2", ".zst")) and not low.endswith( (".tar.gz", ".tar.xz", ".tar.bz2", ".tar.zst")): # Straight to the file: the content need not be text. return make(outdir, s, lambda t: run(["sh", "-c", 'exec bsdcat -- "$1" > "$2"', "sh", f, t])) return make(outdir, s, lambda t: run(["bsdtar", "-xf", f, "-C", t]), folder=True) raise Fail(f"unknown operation {op}") def compress(op, outdir, files): ext = {"zip": ".zip", "tgz": ".tar.gz", "txz": ".tar.xz", "tbz2": ".tar.bz2"}[op] # A dotfile's archive is not hidden: .bashrc gives bashrc.zip. name = ((stem(files[0]).lstrip(".") if len(files) == 1 else "") or "archive") + ext out = os.path.realpath(outdir) def wanted(p): # Regular files only, and none inside the output folder: zipping a # folder that holds it would read the archive being written, which # never ends. A FIFO would block, a dangling link would fail. real = os.path.realpath(p) return os.path.isfile(p) and not real.startswith(out + os.sep) def build(t): if op == "zip": with zipfile.ZipFile(t, "w", zipfile.ZIP_DEFLATED) as z: for f in files: if os.path.isdir(f): for root, _, names in os.walk(f): for n in names: p = os.path.join(root, n) if wanted(p): z.write(p, os.path.relpath(p, os.path.dirname(f))) else: z.write(f, os.path.basename(f)) else: with tarfile.open(t, "w:" + {"tgz": "gz", "txz": "xz", "tbz2": "bz2"}[op]) as tar: for f in files: tar.add(f, arcname=os.path.basename(f), filter=lambda i: None if i.isfifo() or i.isdev() else i) return make(outdir, name, build) def sweep(outdir): # Leftovers of a run that was killed outright (its pid is gone). for n in os.listdir(outdir): if n.startswith(".fileops-"): pid = n.split("-")[1] if pid.isdigit() and not os.path.exists(f"/proc/{pid}"): p = os.path.join(outdir, n) shutil.rmtree(p, ignore_errors=True) if os.path.isdir(p) else _unlink(p) def main(argv): if len(argv) < 3: raise Fail("usage: fileops ") op, outdir, files = argv[0], argv[1], argv[2:] if not os.path.isabs(outdir): raise Fail("the output folder must be an absolute path") for f in files: if not (os.path.isabs(f) and os.path.exists(f)): raise Fail(f"cannot read {os.path.basename(f)}") os.makedirs(outdir, exist_ok=True) sweep(outdir) if op == "pdf-merge": if len(files) < 2: raise Fail("merging needs two or more PDFs") # --warning-exit-0: a PDF with a repaired xref merges fine, and qpdf # would otherwise exit 3 and the result be thrown away. return [make(outdir, "merged.pdf", lambda t: run(["qpdf", "--warning-exit-0", "--empty", "--pages", *files, "--", t]))] if op in ("zip", "tgz", "txz", "tbz2"): return [compress(op, outdir, files)] return [each(op, outdir, f) for f in files] if __name__ == "__main__": signal.signal(signal.SIGTERM, stop) try: out = main(sys.argv[1:]) except Fail as e: for t in temps: shutil.rmtree(t, ignore_errors=True) if os.path.isdir(t) else _unlink(t) finish({"ok": False, "error": str(e)}, 1) except Exception as e: # a bug: say so rather than hang the card for t in temps: shutil.rmtree(t, ignore_errors=True) if os.path.isdir(t) else _unlink(t) finish({"ok": False, "error": f"{type(e).__name__}: {e}"}, 1) finish({"ok": True, "out": out}, 0)