single-file mode: --path / --dest / --keep-source

A freshly downloaded replacement has to go through the SAME pipeline as the library - same bpp
tier rule, same gates, same hash-verified install - rather than a hand-rolled ffmpeg one-off. So:
--path converts one file anywhere on disk (its tier is still chosen by bpp; the dest defaults next
to it), --dest installs the result at an exact path (e.g. back into the library), --keep-source
never deletes the source after a verified install. Also fixes a cross-drive crash: os.path.relpath
raises ValueError when the file is on D: and the library on Z:, so logs/ledger fall back to the
absolute path (measured on the first run of this feature).
This commit is contained in:
2026-10-07 13:18:07 +08:00
parent 5071ddfc8c
commit 66a80e2752
+39 -10
View File
@@ -94,6 +94,18 @@ def log(*a):
print(time.strftime("%H:%M:%S") + " " + " ".join(str(x) for x in a), flush=True)
def disp(p):
"""Path for logs/ledger: relative to the library when possible.
A single-file run can point anywhere (measured: os.path.relpath raises ValueError when the file
is on D: and the library on Z:), so fall back to the absolute path.
"""
try:
return os.path.relpath(p, ROOT)
except ValueError:
return p
def probe(p, tries=3):
"""T.probe with retries: this share times a probe out (measured: 120 s for a 30 MB read), and
one hiccup must not fail an item that is otherwise fine. The per-item try/except in the main
@@ -675,7 +687,8 @@ def process(src, args, dest=None, origin=None):
os.replace(remote_tmp, live)
# NEVER delete the source when it came from the snapshot origin: that is the archive of
# the originals, and removing it would destroy the only clean copy of the library.
if not origin_used and os.path.abspath(live) != os.path.abspath(src):
if not origin_used and not getattr(args, "keep_source", False) \
and os.path.abspath(live) != os.path.abspath(src):
os.remove(src)
back = probe(live)
tag_needed = (tier != "copy")
@@ -742,6 +755,13 @@ def main():
ap.add_argument("--nvenc-preset", default=NVENC_PRESET)
ap.add_argument("--limit", type=int, default=0)
ap.add_argument("--only", default=None, help="substring filter")
ap.add_argument("--path", default=None,
help="convert this ONE file instead of the library tree (its tier is still "
"chosen by the same bpp rule); pair with --dest to install it elsewhere")
ap.add_argument("--dest", default=None,
help="install the result at this exact path (default: next to the source)")
ap.add_argument("--keep-source", action="store_true",
help="never delete the source file after a verified install")
ap.add_argument("--reverse", action="store_true")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--no-decode-check", action="store_true")
@@ -753,7 +773,13 @@ def main():
DECODE_CHECK = not args.no_decode_check
os.makedirs(STAGE, exist_ok=True)
files = collect(ROOT)
if args.path:
files = [args.path]
if not os.path.exists(args.path):
log("!!! no such file: %s" % args.path)
return
else:
files = collect(ROOT)
if args.only:
files = [f for f in files if args.only in f]
if args.reverse:
@@ -766,7 +792,7 @@ def main():
# One lock PER TIER, not one global lock: the copy tier uses no encoder, the x265 tier is CPU
# and the nvenc tier is the GPU ASIC, so they are safe to run side by side whereas two runs of
# the same tier would race on dst and starve a shared unit.
lockf = os.path.join(WORK, "run-%s.lock" % (args.tier or "all"))
lockf = os.path.join(WORK, "run-%s.lock" % (args.tier or ("single" if args.path else "all")))
log("=== %d file(s) tier=%s cq=%d crf=%d audio=%s decode_check=%s lock=%s" %
(len(files), args.tier or "auto", args.cq, args.crf, args.audio or "source-matched",
DECODE_CHECK, os.path.basename(lockf)))
@@ -776,7 +802,7 @@ def main():
for f in files:
info = probe(f)
if not info:
log("PLAN %s -> UNREADABLE" % os.path.relpath(f, ROOT))
log("PLAN %s -> UNREADABLE" % disp(f))
continue
vbps = src_video_bps(f, info)
b = bpp_of(info, vbps)
@@ -784,7 +810,7 @@ def main():
plan[t] = plan.get(t, 0) + 1
log("PLAN %-5s bpp=%.4f %sx%s@%.0f %6.0f kbps %7.1f MB %s" % (
t, b, info["w"], info["h"], src_fps(info), vbps / 1000, info["size"] / 1e6,
os.path.relpath(f, ROOT)[:60]))
disp(f)[:60]))
log("=== plan: %s" % plan)
return
@@ -796,14 +822,17 @@ def main():
if free < 8:
log("!!! ABORT: only %.1f GB free on C: - stopping" % free)
break
log("--- %d/%d %s" % (i, len(files), os.path.relpath(f, ROOT)))
log("--- %d/%d %s" % (i, len(files), disp(f)))
t0 = time.time()
origin = None
if args.origin_root:
cand = os.path.join(args.origin_root, os.path.relpath(f, ROOT))
origin = cand if os.path.exists(cand) else None
try:
cand = os.path.join(args.origin_root, os.path.relpath(f, ROOT))
except ValueError:
cand = None
origin = cand if cand and os.path.exists(cand) else None
try:
status, extra = process(f, args, dest=dst_path_for(f), origin=origin)
status, extra = process(f, args, dest=args.dest or dst_path_for(f), origin=origin)
except Exception as e:
status, extra = "FAIL %s: %s" % (type(e).__name__, e), None
el = time.time() - t0
@@ -819,7 +848,7 @@ def main():
stats["fail"] += 1
log(" !! %s" % status)
ledger.write(json.dumps({"t": time.strftime("%Y-%m-%dT%H:%M:%S"),
"path": os.path.relpath(f, ROOT), "status": status,
"path": disp(f), "status": status,
"secs": round(el, 1), "extra": extra},
ensure_ascii=False) + "\n")
ledger.flush()