diff options
| -rw-r--r-- | README.md | 30 | ||||
| -rwxr-xr-x | transcode.sh | 169 | ||||
| -rw-r--r-- | transcode.sh.1 | 29 | ||||
| -rw-r--r-- | transcode.sh.bash-completion | 11 |
4 files changed, 230 insertions, 9 deletions
@@ -21,6 +21,8 @@ replaces the original atomically on success. - **Size report** (`-s`) — print per-file size feedback, show a batch summary, and write a status TSV log for encoded, skipped, and failed files. - **Dry-run** (`-n`) — preview what would happen without touching any files. +- **Backup original** (`--backup-dir DIR`) — copy originals to an existing + backup directory before replacing them. - **Nice** — runs ffmpeg at niceness 19 by default to avoid starving other processes; configurable with `-N`. - **Respects [NO\_COLOR](https://no-color.org/)**. @@ -140,8 +142,9 @@ unparseable file is a hard error. Requires `tomlq` when the file is present. ```toml [encoding] -preset = "av1" -nice = 10 +preset = "av1" +nice = 10 +backup_dir = "backups" [skip] codecs = ["av1", "hevc"] @@ -157,15 +160,16 @@ quiet = false | Section | Key | Type | CLI equivalent | |---------|-----|------|----------------| -| `[encoding]` | `preset` | string | `-p` | +| `[encoding]` | `backup_dir` | string | `--backup-dir` | +| `[encoding]` | `hwaccel` | string or boolean | `--hwaccel` / `--no-hwaccel` | | `[encoding]` | `nice` | integer | `-N` | +| `[encoding]` | `preset` | string | `-p` | | `[encoding]` | `verify_output` | boolean | `--verify-output` | -| `[encoding]` | `hwaccel` | string or boolean | `--hwaccel` / `--no-hwaccel` | -| `[skip]` | `codecs` | array of strings | `-S` (merged with CLI) | +| `[output]` | `quiet` | boolean | `-q` | +| `[output]` | `verbose` | boolean | `-v` | | `[size_report]` | `enabled` | boolean | `-s` / `--no-size-report` | | `[size_report]` | `file` | string | `--size-report-file` | -| `[output]` | `verbose` | boolean | `-v` | -| `[output]` | `quiet` | boolean | `-q` | +| `[skip]` | `codecs` | array of strings | `-S` (merged with CLI) | > **Note:** `[skip] codecs` from the config file and `--skip-codec` from the > CLI are **merged** — both sources contribute to the skip list. @@ -174,6 +178,9 @@ quiet = false > or a method string such as `"vaapi"` or `"cuda"`. CLI `--hwaccel`/`--no-hwaccel` > always takes precedence. +> **Note:** `[encoding] backup_dir` must point to an existing directory. CLI +> `--backup-dir` always takes precedence. + ### Codec skip list (deprecated) The legacy `skip.conf` file is no longer read. If it still exists, a @@ -195,6 +202,7 @@ transcode.sh [OPTION] [--] FILE... | `--config-file FILE`, `--config-file=FILE` | Load configuration from FILE instead of the default path | | `-f PATH`, `--encode-file PATH`, `--encode-file=PATH` | Read file list from PATH (one per line) | | `-n`, `--dry-run` | Show what would be done; don't run ffmpeg | +| `--backup-dir DIR`, `--backup-dir=DIR` | Copy originals to an existing backup directory before replacing them | | `-N VALUE`, `--nice VALUE`, `--nice=VALUE` | Nice adjustment for ffmpeg (default: 19) | | `--hwaccel [METHOD]`, `--hwaccel=METHOD` | Enable hardware acceleration; METHOD defaults to `auto` if omitted (e.g. `cuda`, `vaapi`, `videotoolbox`); enabled by default | | `--no-hwaccel` | Disable hardware acceleration (omit `-hwaccel` from ffmpeg invocation) | @@ -224,6 +232,9 @@ transcode.sh --size-report --encode-file list.txt # Dry-run with verbose output to inspect detected formats transcode.sh --dry-run --verbose my_movie.mkv +# Keep originals in an existing backup directory after successful encodes +transcode.sh --backup-dir ~/transcode-backups *.mp4 + # Use a custom preset, only keep result if smaller, skip AV1 inputs transcode.sh --preset av1_fast --only-if-smaller --skip-codec av1 /media/films/*.mkv @@ -234,6 +245,11 @@ transcode.sh --nice 10 --continue *.mp4 transcode.sh --config-file ~/profiles/fast.toml input.mp4 ``` +When `--backup-dir` is used, the directory must already exist. Backups preserve +the source file's absolute path below that directory and existing backup files +are not overwritten; numeric suffixes such as `.1` and `.2` are appended when +needed. + ## Size report TSV When `--size-report` is enabled, the TSV log uses this schema: diff --git a/transcode.sh b/transcode.sh index b88c9d8..3290596 100755 --- a/transcode.sh +++ b/transcode.sh @@ -151,6 +151,7 @@ DEBUG=${DEBUG:-0} QUIET=0 VERBOSE=0 +BACKUP_DIR="" CONTINUE_ON_FAIL=0 DRY_RUN=0 ENCODE_FILE="" @@ -175,6 +176,7 @@ readonly SKIP_CODECS_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/transcode.sh/skip.c # Sentinel flags: set to 1 by the CLI option parser so that load_config() knows # which values have already been provided and must not be overridden. +_CLI_BACKUP_DIR=0 _CLI_CONFIG_FILE=0 _CLI_CONTINUE=0 _CLI_HWACCEL=0 @@ -281,6 +283,7 @@ ${BOLD}${BLUE}Encoding options:${ALL_OFF} ${BOLD}${YELLOW}-p, --preset${ALL_OFF} ${BOLD}${GREEN}NAME${ALL_OFF} load ffmpeg arguments from a preset (default: ${BOLD}${RED}%s${ALL_OFF}) ${BOLD}${YELLOW}-N, --nice${ALL_OFF} ${BOLD}${GREEN}VALUE${ALL_OFF} nice adjustment for ffmpeg (default: ${BOLD}${RED}%d${ALL_OFF}) ${BOLD}${YELLOW}-n, --dry-run${ALL_OFF} show what would be done, do not run ffmpeg + ${BOLD}${YELLOW} --backup-dir${ALL_OFF} ${BOLD}${GREEN}DIR${ALL_OFF} backup original files to DIR instead of overwriting them ${BOLD}${YELLOW} --hwaccel${ALL_OFF} ${BOLD}${GREEN}[METHOD]${ALL_OFF} enable hardware acceleration; METHOD defaults to ${BOLD}${RED}auto${ALL_OFF} if omitted ${BOLD}${YELLOW} --no-hwaccel${ALL_OFF} disable hardware acceleration (do not pass ${BOLD}${YELLOW}-hwaccel${ALL_OFF} to ffmpeg) @@ -403,6 +406,14 @@ format_filesize() { numfmt --to=iec -- "$1" } +# Return PATH without a leading slash so it can safely be placed below another +# directory while still preserving its absolute path structure. +strip_leading_slash() { + local path="$1" + path="${path#/}" + printf '%s\n' "$path" +} + ############################################################################### # TERMINAL COLOR HANDLING # @@ -576,6 +587,14 @@ load_config() { fi fi + if [[ $_CLI_BACKUP_DIR -eq 0 ]]; then + val=$(_tomlq_get '.encoding.backup_dir // empty') + if [[ -n "$val" ]]; then + debug "Config: encoding.backup_dir =" "$val" + BACKUP_DIR="$val" + fi + fi + # [skip] codecs (TOML array → one element per line via tomlq -r '.skip.codecs[]') # CLI --skip-codec entries are additive, so we always load config codecs # regardless of _CLI_SKIP_CODEC. Both sources merge into SKIP_CODECS. @@ -907,6 +926,103 @@ print_size_report_summary() { } ############################################################################### +# BACKUP HANDLING +############################################################################### + +# Return an unused backup path. Existing backups are never overwritten; numeric +# suffixes are appended until a free name is found. +unique_backup_path() { + local target="$1" + + if [[ ! -e "$target" ]]; then + printf '%s\n' "$target" + return $EXIT_OK + fi + + local candidate counter + counter=1 + while :; do + candidate="${target}.${counter}" + if [[ ! -e "$candidate" ]]; then + printf '%s\n' "$candidate" + return $EXIT_OK + fi + ((counter++)) + done +} + +# Copy the original file to BACKUP_DIR, preserving the absolute source path +# below that directory, then move the encoded temporary output into the original +# location. The original remains in place until the final replacement step. +backup_then_replace_original() { + local input_file="$1" + local temporary_output_file="$2" + + debug "Preparing backup replacement for:" "$input_file" + + local resolved_input relative_backup_path backup_target backup_parent + + resolved_input=$(resolve_path "$input_file") + debug "Resolved input path:" "$resolved_input" + + relative_backup_path=$(strip_leading_slash "$resolved_input") + debug "Relative backup path:" "$relative_backup_path" + + backup_target=$(unique_backup_path "$BACKUP_DIR/$relative_backup_path") + debug "Selected backup target:" "$backup_target" + + backup_parent=$(dirname -- "$backup_target") + + mkdir -p -- "$backup_parent" || { + error "Could not create backup parent directory:" "$backup_parent" + return $EXIT_RUNTIME_FAILURE + } + + if ! cp -p -- "$input_file" "$backup_target"; then + error "Could not copy original to backup directory:" "$backup_target" + return $EXIT_RUNTIME_FAILURE + else + debug "Original copied to backup:" "$backup_target" + fi + + if [[ ! -r "$backup_target" ]]; then + error "Backup copy is not readable:" "$backup_target" + return $EXIT_RUNTIME_FAILURE + else + verbose "Backed up original to:" "$backup_target" + fi + + if mv -f -- "$temporary_output_file" "$input_file"; then + msg "Replaced:" "$input_file" + return $EXIT_OK + fi + + error "Could not move encoded output:" "$input_file" + error "Original file was left in place:" "$input_file" + return $EXIT_RUNTIME_FAILURE +} + +# Replace the original with the encoded output. If BACKUP_DIR is set, preserve +# the original instead of deleting it. +replace_original() { + local input_file="$1" + local temporary_output_file="$2" + + if [[ -n "$BACKUP_DIR" ]]; then + backup_then_replace_original "$input_file" "$temporary_output_file" + return $? + fi + + if mv -f -- "$temporary_output_file" "$input_file"; then + msg "Replaced:" "$input_file" + return $EXIT_OK + fi + + error "Could not overwrite:" "$input_file" + return $EXIT_RUNTIME_FAILURE +} + +############################################################################### # ENCODING ############################################################################### @@ -1192,15 +1308,25 @@ encode_one() { return $EXIT_OK fi + if ! replace_original "$input_file" "$temporary_output_file"; then + ((_REPORT_ENCODING_FAILED++)) + append_size_report_row "failed" "$filename" "$original_filesize" "$new_filesize" + error "Replacement FAILED:" "$input_file" + if [[ $CONTINUE_ON_FAIL -eq 1 ]]; then + return $EXIT_RUNTIME_FAILURE + else + print_size_report_summary "$amount_total_files" + exit $EXIT_RUNTIME_FAILURE + fi + fi + append_size_report_row "encoded" "$filename" "$original_filesize" "$new_filesize" "$filesize_percentage" print_size_report_feedback "encoded" "$original_filesize" "$new_filesize" "$filesize_difference" "$filesize_percentage" - mv -f -- "$temporary_output_file" "$input_file" ((_REPORT_SUCCEEDED++)) _REPORT_TOTAL_ORIGINAL_BYTES=$((_REPORT_TOTAL_ORIGINAL_BYTES + original_filesize)) _REPORT_TOTAL_NEW_BYTES=$((_REPORT_TOTAL_NEW_BYTES + new_filesize)) _REPORT_TOTAL_SAVED_BYTES=$((_REPORT_TOTAL_SAVED_BYTES + filesize_difference)) - msg "Replaced:" "$input_file" else ((_REPORT_ENCODING_FAILED++)) append_size_report_row "failed" "$filename" "$(filesize "$input_file")" @@ -1303,6 +1429,28 @@ while [[ $# -gt 0 ]]; do _CLI_VERIFY_OUTPUT=1 shift ;; + --backup-dir) + if [[ $# -lt 2 ]]; then + error "Missing value for $1" + exit $EXIT_USAGE_ERROR + fi + if [[ ! -n "$2" ]]; then + error "Value for $1 must not be empty" + exit $EXIT_USAGE_ERROR + fi + BACKUP_DIR="$2" + _CLI_BACKUP_DIR=1 + shift 2 + ;; + --backup-dir=*) + if [[ ! -n "${1#*=}" ]]; then + error "Value for --backup-dir must not be empty" + exit $EXIT_USAGE_ERROR + fi + BACKUP_DIR="${1#*=}" + _CLI_BACKUP_DIR=1 + shift + ;; --config-file) if [[ $# -lt 2 ]]; then error "Missing value for $1" @@ -1495,6 +1643,7 @@ load_config printf -v joined ' %s' "${!SKIP_CODECS[@]}" debug "Effective configuration:" +debug " backup-dir:" "$BACKUP_DIR" debug " config-file:" "$CONFIG_FILE" debug " dry-run:" "$DRY_RUN" debug " encode-file:" "$ENCODE_FILE" @@ -1527,6 +1676,22 @@ if [[ $_CLI_SIZE_REPORT_FILE -eq 1 && $SIZE_REPORT -eq 0 ]]; then warn "--size-report-file has no effect without --size-report" fi +if [[ -n "$BACKUP_DIR" ]]; then + if [[ ! -e "$BACKUP_DIR" ]]; then + error "Backup directory does not exist:" "$BACKUP_DIR" + exit $EXIT_USAGE_ERROR + fi + if [[ ! -d "$BACKUP_DIR" ]]; then + error "Backup path is not a directory:" "$BACKUP_DIR" + exit $EXIT_USAGE_ERROR + fi + if [[ ! -w "$BACKUP_DIR" || ! -x "$BACKUP_DIR" ]]; then + error "Backup directory is not writable/executable:" "$BACKUP_DIR" + exit $EXIT_USAGE_ERROR + fi + BACKUP_DIR=$(resolve_path "$BACKUP_DIR") +fi + if [[ ! "$NICE_VALUE" =~ ^-?[0-9]+$ ]]; then error "Invalid nice value (must be an integer):" "$NICE_VALUE" exit $EXIT_USAGE_ERROR diff --git a/transcode.sh.1 b/transcode.sh.1 index c95f74e..c827336 100644 --- a/transcode.sh.1 +++ b/transcode.sh.1 @@ -86,6 +86,12 @@ Combine with .B \-v to see the detected codec and pixel format information. .TP +.BR \-\-backup\-dir " \fIDIR\fR, " \-\-backup\-dir= \fIDIR\fR +Copy originals to \fIDIR\fR before replacing them after successful encoding. +\fIDIR\fR must already exist; the script exits with a usage error if it does +not. Backups preserve the source file's absolute path below \fIDIR\fR, and +existing backup files are not overwritten. +.TP .BR \-\-hwaccel " [\fIMETHOD\fR], " \-\-hwaccel= \fIMETHOD\fR Pass .BI \-hwaccel " METHOD" @@ -226,6 +232,23 @@ which prints every command as it is executed. .B XDG_CONFIG_HOME Base directory for user configuration. Defaults to .BR $HOME/.config . +.SH BACKUPS +When +.BR \-\-backup\-dir +is used, backups are only created after +.BR ffmpeg (1) +completed successfully and, when enabled, the integrity check passed. Codec +skips, failed encodes, failed integrity checks, dry runs, and +.BR \-\-only\-if\-smaller +size skips leave the original file in place and create no backup. +.PP +The backup directory itself must already exist. Parent directories below it are +created as needed to preserve the input file path. If the intended backup file +already exists, a numeric suffix such as +.B .1 +or +.B .2 +is appended. .SH SIZE REPORT TSV When .BR \-\-size\-report @@ -369,6 +392,12 @@ Boolean. Equivalent to Defaults to .BR true . .TP +.B [encoding] backup_dir +String. Existing directory where originals are copied after successful +encoding and before replacement. Equivalent to +.BR \-\-backup\-dir . +CLI \fB--backup-dir\fR takes precedence. +.TP .B [skip] codecs Array of strings. Merged with any codecs from .BR \-S . diff --git a/transcode.sh.bash-completion b/transcode.sh.bash-completion index 7c6eefa..7a0529d 100644 --- a/transcode.sh.bash-completion +++ b/transcode.sh.bash-completion @@ -50,6 +50,11 @@ _transcode_sh() { _filedir return ;; + --backup-dir=*) + cur="${cur#*=}" + _filedir -d + return + ;; --encode-file=*) cur="${cur#*=}" _filedir @@ -92,6 +97,10 @@ _transcode_sh() { _filedir return ;; + --backup-dir) + _filedir -d + return + ;; -f | --encode-file) _filedir return @@ -128,6 +137,8 @@ _transcode_sh() { case "$cur" in --*) local longopts=' + --backup-dir + --backup-dir= --color --config-file --config-file= |
