summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
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=