aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDennis Fink2026-05-16 12:39:15 +0200
committerDennis Fink2026-05-16 12:39:15 +0200
commit054a300fe5b81ed04f84e902e05c4f4efb95a193 (patch)
tree73eacbd9acd751a41ad326a23b29764e1475445f
parent4bb37025cf1b4516acd2746dc60ec59449e8aab0 (diff)
downloadtranscode.sh-054a300fe5b81ed04f84e902e05c4f4efb95a193.tar.gz
transcode.sh-054a300fe5b81ed04f84e902e05c4f4efb95a193.zip
feat(backup): add original file backups
Add --backup-dir support so successful encodes can copy the original file to an existing backup directory before replacing it. Preserve each source file's absolute path below the backup directory and avoid overwriting existing backups by appending numeric suffixes when needed. Document the new CLI and config option, and add directory completion for the backup path.
Diffstat (limited to '')
-rw-r--r--README.md30
-rwxr-xr-xtranscode.sh169
-rw-r--r--transcode.sh.129
-rw-r--r--transcode.sh.bash-completion11
4 files changed, 230 insertions, 9 deletions
diff --git a/README.md b/README.md
index fd40cfe..32f6a0c 100644
--- a/README.md
+++ b/README.md
@@ -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=