move scripts into bin folder for better organizing
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# Recursively finds and compresses video files to AV1/MP4 format with reduced quality/size.
|
||||
# Converts to 1080p max, reduces high framerates, and uses low bitrate audio.
|
||||
# Original files are removed after successful compression.
|
||||
|
||||
# Set IFS to newline only to properly handle filenames with spaces
|
||||
# Disable globbing with set -f to prevent special character interpretation
|
||||
IFS=$'\n'; set -f
|
||||
|
||||
# Set up trap to handle Ctrl+C (SIGINT) gracefully
|
||||
# This ensures clean exit when user interrupts script execution
|
||||
trap "exit" INT
|
||||
|
||||
for f in $(find $PWD \( -iname '*.mp4' -or -iname '*.mkv' -or -iname '*.avi' -or -iname '*.wmv' -or -iname '*.mov' -or -iname '*.m4v' -or -iname '*.ts' -or -iname '*flv' -or -iname '*mpg' \) -and -not -name '*.cmp.*' | sort --reverse); do
|
||||
if grep -q "\.cmp\." <<< "$f"; then
|
||||
echo -e "\033[0;32mSkipping\033[0m $f, already compressed"
|
||||
else
|
||||
if grep -q "\.wip\." <<< "$f"; then
|
||||
echo -e "\033[0;31mRemoving unfinished conversion file:\033[0m $f"
|
||||
rm $f
|
||||
else
|
||||
if [ ! -f ${f%.*}.cmp.mp4 ]; then
|
||||
ffmpeg -y -i $f -vf "fps='if(gte(source_fps,100),source_fps/4,if(gte(source_fps,50),source_fps/2,source_fps))',scale=-2:'min(1080,ih)'" -c:v libsvtav1 -crf 40 -c:a aac -b:a 64k ${f%.*}.wip.mp4 \
|
||||
&& mv "${f%.*}.wip.mp4" "${f%.*}.cmp.mp4" && rm $f
|
||||
else
|
||||
echo -e "\033[0;34mRemoving\033[0m $f, already compressed"
|
||||
rm $f
|
||||
fi
|
||||
rm -f "${f%.*}.wip.mp4"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
unset IFS; set +f
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/bin/bash
|
||||
# Watch this folder for incoming files
|
||||
# Rename each file to a hash of some kind
|
||||
# Put each file into a folder of the old name
|
||||
|
||||
# Check for .known-files which tracks hash history to prevent duplicates
|
||||
# Will create if -f flag is used, otherwise exits to prevent accidental runs
|
||||
if [[ ! -f .known-files ]]; then
|
||||
if [[ "$1" != "-f" ]]; then
|
||||
echo ".known-files not found. Use -f to override."
|
||||
exit 1
|
||||
fi
|
||||
touch .known-files
|
||||
fi
|
||||
|
||||
command -v file >/dev/null 2>&1 || { echo "file command not found"; exit 1; }
|
||||
command -v magick >/dev/null 2>&1 || { echo "imagemagick not found"; exit 1; }
|
||||
command -v dctfilename >/dev/null 2>&1 || { echo "dctfilename not found"; exit 1; }
|
||||
command -v webpmux >/dev/null 2>&1 || { echo "webpmux not found"; exit 1; }
|
||||
command -v gifski >/dev/null 2>&1 || { echo "gifski not found"; exit 1; }
|
||||
|
||||
while true; do
|
||||
for f in *.*; do
|
||||
# Check if we need to skip
|
||||
[ -e "$f" ] || continue
|
||||
[ -e "$f.part" ] && continue
|
||||
[ ${f##*.} == "part" ] && continue
|
||||
[ ${f##*.} == "sh" ] && continue
|
||||
|
||||
# Print some file info, and check file validity
|
||||
FILEINFO=`file $f`
|
||||
echo $FILEINFO
|
||||
if echo "$FILEINFO" | grep -q empty; then
|
||||
continue
|
||||
fi
|
||||
if ! echo "$FILEINFO" | grep -iq "image\|video"; then
|
||||
continue
|
||||
echo "skip"
|
||||
fi
|
||||
|
||||
# Handle webp as a special case, and defer to next iteration
|
||||
if [ ${f##*.} == "webp" ]
|
||||
then
|
||||
if [ `magick identify $f | wc -l` -gt 1 ]
|
||||
then
|
||||
echo "Animated webp. Converting to gif..."
|
||||
TMPDIR=$(mktemp -d)
|
||||
magick "$f" "$TMPDIR/frame-%d.png"
|
||||
DELAYS=$(webpmux -info "$f" | tail -n+6 | awk '{print $7}')
|
||||
FPS=$(echo "$DELAYS" | head -n1 | awk '{printf "%.0f", 1000/$1}')
|
||||
gifski --fps $FPS "$TMPDIR"/frame-*.png -o "${f%.*}.gif"
|
||||
rm -rf "$TMPDIR"
|
||||
rm "$f"
|
||||
continue
|
||||
else
|
||||
echo "Picture webp. Converting to jpg..."
|
||||
magick "$f" "${f%.*}.jpg"
|
||||
rm "$f"
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
# Determine phash and destination folder
|
||||
if command -v dctfilename >/dev/null 2>&1 && ! echo "$FILEINFO" | grep -iq "video"; then
|
||||
NAME=`dctfilename $f`
|
||||
else
|
||||
NAME=`md5sum $f | awk '{print $1}'`
|
||||
fi
|
||||
FOLDER=${f%.*}
|
||||
FOLDER=${FOLDER%\(*}
|
||||
|
||||
# Check if the file's hash already exists in .known-files
|
||||
if grep -F -q $NAME .known-files; then
|
||||
echo "$NAME is a duplicate file"
|
||||
mkdir -p duplicates
|
||||
mv -f $f "duplicates/$NAME.${f##*.}"
|
||||
else
|
||||
# For new files, add hash to .known-files and move to destination folder
|
||||
echo $NAME >> .known-files
|
||||
mkdir -p $FOLDER
|
||||
echo "Moving '$f' to '$FOLDER/$NAME.${f##*.}'"
|
||||
mv -f $f "$FOLDER/$NAME.${f##*.}"
|
||||
fi
|
||||
echo '------'
|
||||
done
|
||||
sleep 1
|
||||
done
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
# This script continuously monitors a source directory and moves any files matching the specified extension
|
||||
# to a destination directory. It checks for new files every interval (default 10 seconds).
|
||||
# Usage: ./script.sh --source-dir|-s <source_dir> --dest-dir|-d <dest_dir> --extension|-e <extension> [--interval|-i <seconds>]
|
||||
# Or set MV_SOURCE_DIR, MV_DEST_DIR, MV_EXTENSION and MV_INTERVAL environment variables before running
|
||||
# Example: ./script.sh -s /home/user/downloads -d /home/user/documents -e "*.pdf" -i 30
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--source-dir|-s) MV_SOURCE_DIR="$2"; shift 2 ;;
|
||||
--dest-dir|-d) MV_DEST_DIR="$2"; shift 2 ;;
|
||||
--extension|-e) MV_EXTENSION="$2"; shift 2 ;;
|
||||
--interval|-i) MV_INTERVAL="$2"; shift 2 ;;
|
||||
*) echo "Unknown parameter: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
MV_SOURCE_DIR=${MV_SOURCE_DIR:-$MV_SOURCE_DIR}
|
||||
MV_DEST_DIR=${MV_DEST_DIR:-$MV_DEST_DIR}
|
||||
MV_EXTENSION=${MV_EXTENSION:-$MV_EXTENSION}
|
||||
MV_INTERVAL=${MV_INTERVAL:-10}
|
||||
|
||||
if [ -z "$MV_SOURCE_DIR" ] || [ -z "$MV_DEST_DIR" ] || [ -z "$MV_EXTENSION" ]; then
|
||||
echo "Error: MV_SOURCE_DIR, MV_DEST_DIR and MV_EXTENSION must be provided via environment variables or arguments"
|
||||
echo "Usage: $0 --source-dir|-s <source_dir> --dest-dir|-d <dest_dir> --extension|-e <extension> [--interval|-i <seconds>]"
|
||||
echo "Or set MV_SOURCE_DIR, MV_DEST_DIR, MV_EXTENSION and MV_INTERVAL environment variables"
|
||||
echo "Example: $0 --source-dir /home/user/downloads --dest-dir /home/user/documents --extension *.pdf --interval 30"
|
||||
echo "Or: export MV_SOURCE_DIR=/home/user/downloads MV_DEST_DIR=/home/user/documents MV_EXTENSION=*.pdf MV_INTERVAL=30"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "$MV_DEST_DIR" ]; then
|
||||
echo "Error: Destination directory $MV_DEST_DIR does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
shopt -s nullglob # This prevents errors when no matches are found
|
||||
while true; do
|
||||
for file in "$MV_SOURCE_DIR"/$MV_EXTENSION; do
|
||||
if [ -f "$file" ]; then
|
||||
echo "Moving: $file"
|
||||
mv "$file" "$MV_DEST_DIR"
|
||||
fi
|
||||
done
|
||||
sleep $MV_INTERVAL
|
||||
done
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Script to send push notifications via Pushover API
|
||||
# Usage: pushover -m|--message <message> [-t|--title <title>] [-a|--attachment <attachment>]
|
||||
# Requires PUSHOVER_APP_TOKEN and PUSHOVER_USER_TOKEN environment variables
|
||||
|
||||
if [ -z "$PUSHOVER_APP_TOKEN" ] || [ -z "$PUSHOVER_USER_TOKEN" ]; then
|
||||
echo "Error: Missing required environment variables"
|
||||
echo "Please set PUSHOVER_APP_TOKEN and PUSHOVER_USER_TOKEN"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MESSAGE=""
|
||||
TITLE=$HOSTNAME
|
||||
ATTACHMENT=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
-m|--message)
|
||||
MESSAGE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-t|--title)
|
||||
TITLE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-a|--attachment)
|
||||
ATTACHMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Invalid option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$MESSAGE" ]; then
|
||||
echo "Error: Message is required"
|
||||
echo
|
||||
echo "Usage: pushover -m|--message <message> [-t|--title <title>] [-a|--attachment <attachment>]"
|
||||
echo "Options:"
|
||||
echo " -m, --message <message> Message text to send (required)"
|
||||
echo " -t, --title <title> Notification title (optional, defaults to hostname)"
|
||||
echo " -a, --attachment <attachment> File to attach (optional, supports images, PDFs etc)"
|
||||
echo
|
||||
echo "Example:"
|
||||
echo " pushover --message \"Backup complete\" --title \"Server Status\" --attachment /path/to/image.png"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$ATTACHMENT" ]; then
|
||||
FILESIZE=$(stat -f%z "$ATTACHMENT" 2>/dev/null || stat -c%s "$ATTACHMENT" 2>/dev/null)
|
||||
if [ "$FILESIZE" -gt 5242880 ]; then
|
||||
echo "Error: Attachment size exceeds Pushover's 5MB limit"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
curl -s \
|
||||
--form-string "token=${PUSHOVER_APP_TOKEN}" \
|
||||
--form-string "user=${PUSHOVER_USER_TOKEN}" \
|
||||
--form-string "message=${MESSAGE}" \
|
||||
--form-string "title=${TITLE}" \
|
||||
${ATTACHMENT:+ -F "attachment=@$ATTACHMENT"} \
|
||||
https://api.pushover.net/1/messages.json
|
||||
@@ -0,0 +1,119 @@
|
||||
#! /usr/bin/python
|
||||
"""Call ffmpeg with youtube-dl to convert files or youtube videos to mp4 or webp files."""
|
||||
|
||||
import subprocess, argparse
|
||||
|
||||
def get_input_args(input_path: str) -> list[str]:
|
||||
"""Generate ffmpeg input arguments for either local files or YouTube URLs."""
|
||||
f_args: list[str] = []
|
||||
if "https" in input_path:
|
||||
urls = subprocess.run(["yt-dlp", "--get-url", input_path], capture_output=True, encoding='utf-8')
|
||||
for url in urls.stdout.split('\n'):
|
||||
if len(url) > 10:
|
||||
f_args += ["-i", url]
|
||||
else:
|
||||
f_args += ["-i", input_path]
|
||||
return f_args
|
||||
|
||||
|
||||
def get_webp_args(quality: str | None) -> list[str]:
|
||||
"""Generate ffmpeg arguments for WebP output format.
|
||||
|
||||
Args:
|
||||
quality (str): Output quality percentage, defaults to 70 if not specified
|
||||
"""
|
||||
args: list[str] = []
|
||||
args += ["-vcodec", "libwebp"]
|
||||
args += ["-preset", "default"]
|
||||
args += ["-loop", "0", "-an", "-vsync", "0"]
|
||||
args += ["-qscale", quality if quality else "70"]
|
||||
return args
|
||||
|
||||
def get_mp4_args() -> list[str]:
|
||||
"""Generate ffmpeg arguments for MP4 output format."""
|
||||
args: list[str] = []
|
||||
args += ["-c:v", "libx264"]
|
||||
args += ["-preset", "veryfast"]
|
||||
args += ["-crf", "26"]
|
||||
args += ["-c:a", "aac"]
|
||||
args += ["-b:a", "128k"]
|
||||
args += ["-pix_fmt", "yuv420p"]
|
||||
return args
|
||||
|
||||
def get_video_filter(crop_arg: str | None) -> str:
|
||||
"""Generate ffmpeg video filter string for rescaling and optional cropping.
|
||||
|
||||
Rescales video height to 720p or original height if smaller, maintaining aspect ratio.
|
||||
Supports standard, percentage-based, and edge-based cropping formats."""
|
||||
vfilter = "scale=-2:'min(720,ih)'"
|
||||
if crop_arg:
|
||||
if crop_arg.endswith('%'):
|
||||
# Percentage-based centered cropping (single percentage value)
|
||||
percent = float(crop_arg.replace('%', ''))
|
||||
vfilter += f",crop='in_w*{percent/100}:in_h*{percent/100}:(in_w-in_w*{percent/100})/2:(in_h-in_h*{percent/100})/2'"
|
||||
elif crop_arg.startswith('edge:'):
|
||||
# Edge-based cropping (edge:left:right:top:bottom or edge:width:height)
|
||||
parts = crop_arg[5:].split(':')
|
||||
if len(parts) == 1:
|
||||
l = r = t = b = int(parts[0])
|
||||
vfilter += f",crop='in_w-{l+r}:in_h-{t+b}:{l}:{t}'"
|
||||
elif len(parts) == 2:
|
||||
w, h = map(int, parts)
|
||||
vfilter += f",crop='{w}:{h}:(in_w-{w})/2:(in_h-{h})/2'"
|
||||
else:
|
||||
l, r, t, b = map(int, parts)
|
||||
vfilter += f",crop='in_w-{l+r}:in_h-{t+b}:{l}:{t}'"
|
||||
else:
|
||||
# Standard cropping (out_w:out_h:x:y or out_w:out_h for centered)
|
||||
parts = crop_arg.split(':')
|
||||
if len(parts) == 2:
|
||||
w, h = parts
|
||||
vfilter += f",crop={w}:{h}:(in_w-{w})/2:(in_h-{h})/2"
|
||||
else:
|
||||
vfilter += f",crop={crop_arg}"
|
||||
return vfilter
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Call ffmpeg for converting small video files to mp4 or webp')
|
||||
parser.add_argument('input', help='input file or url')
|
||||
parser.add_argument('output', help='output filename')
|
||||
parser.add_argument('-s', '--start', help='start time, if not 00:00')
|
||||
parser.add_argument('-t', '--to', help='end time, if not end of file')
|
||||
parser.add_argument('-q', '--quality', help='quality of output, as a percentage')
|
||||
parser.add_argument('-S', '--silent', action='store_true', help='no audio')
|
||||
parser.add_argument('-c', '--crop', help="""
|
||||
Crop formats supported:
|
||||
- Standard: "out_w:out_h:x:y" or "out_w:out_h" for centered
|
||||
- Percentage: "NN%" for centered crop to NN percent
|
||||
- Edge: "edge:left:right:top:bottom" or "edge:width:height" or single value for all""")
|
||||
args = parser.parse_args()
|
||||
|
||||
f_args = get_input_args(args.input)
|
||||
|
||||
f_args += ["-vf", get_video_filter(args.crop)]
|
||||
|
||||
if args.start:
|
||||
f_args += ["-ss", args.start] # -ss specifies the start timestamp
|
||||
if args.to:
|
||||
f_args += ["-to", args.to] # -to specifies the end timestamp
|
||||
if args.silent:
|
||||
f_args += ["-an"] # -an disables audio output
|
||||
f_args += ["-sn"] # -sn removes subtitles
|
||||
f_args += ["-dn"] # -dn removes data streams
|
||||
f_args += ["-map_metadata:c", "-1"] # Remove metadata from the output
|
||||
|
||||
output_ext = args.output.split('.')[-1]
|
||||
if output_ext == 'webp':
|
||||
f_args += get_webp_args(args.quality)
|
||||
elif output_ext == 'mp4':
|
||||
f_args += get_mp4_args()
|
||||
else:
|
||||
print(f"Error: Output file must have either .mp4 or .webp extension, found: .{output_ext}")
|
||||
exit(1)
|
||||
|
||||
print(f_args)
|
||||
|
||||
subprocess.run(["ffmpeg", "-y"] + f_args + [args.output if args.output else "output.mp4"])
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import shutil
|
||||
import sys
|
||||
import argparse
|
||||
import tempfile
|
||||
import logging
|
||||
import time
|
||||
|
||||
video_extensions: list[str] = ["mp4", "mkv", "avi", "wmv", "mov", "m4v", "ts", "flv", "mpg"]
|
||||
|
||||
total_files: int = 0
|
||||
|
||||
def setup_logging(verbose):
|
||||
"""Configure logging to both file and stdout."""
|
||||
log_level = logging.DEBUG if verbose else logging.INFO
|
||||
|
||||
# Use local log directory
|
||||
xdg_state_home = os.environ.get('XDG_STATE_HOME', str(pathlib.Path.home() / '.local' / 'state'))
|
||||
log_dir = pathlib.Path(xdg_state_home) / 'vcmp'
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
log_file = log_dir / 'vcmp.log'
|
||||
|
||||
# Create console handler
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(log_level)
|
||||
console_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
|
||||
|
||||
# Create file handler
|
||||
file_handler = logging.FileHandler(log_file)
|
||||
file_handler.setLevel(logging.DEBUG) # Always log everything to file
|
||||
file_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
|
||||
|
||||
# Set up root logger
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(logging.DEBUG)
|
||||
root_logger.addHandler(console_handler)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
def ffmpeg_command(input, output, nice=20):
|
||||
return [
|
||||
"nice", "-n", str(nice),
|
||||
"ffmpeg",
|
||||
"-progress", "pipe:1", # Send progress info to stdout
|
||||
"-i", str(input),
|
||||
"-vf", "fps='if(gte(source_fps,100),source_fps/4,if(gte(source_fps,50),source_fps/2,source_fps))',scale=-2:'min(1080,ih)'",
|
||||
"-c:v", "libsvtav1",
|
||||
"-crf", "40",
|
||||
"-c:a", "aac",
|
||||
"-b:a", "64k",
|
||||
str(output)
|
||||
]
|
||||
|
||||
def clean_directory(directory_path):
|
||||
"""Yeet all files and directories from a directory."""
|
||||
for item in directory_path.iterdir():
|
||||
if item.is_dir():
|
||||
shutil.rmtree(item) # Yeet the directory
|
||||
else:
|
||||
item.unlink() # Yeet the file
|
||||
|
||||
def is_video(path):
|
||||
return path.suffix.lower()[1:] in video_extensions
|
||||
|
||||
def get_video_duration(input_file):
|
||||
cmd = [
|
||||
"ffprobe",
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
str(input_file)
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
return float(result.stdout)
|
||||
|
||||
def run_progress(process, duration, file_index=0):
|
||||
current_time = None
|
||||
last_update = 0
|
||||
progress = 0
|
||||
UPDATE_INTERVAL = 1
|
||||
start_time = time.time()
|
||||
while True:
|
||||
line = process.stdout.readline() if process.stdout else None
|
||||
|
||||
# Check if we've reached the end of output (no more lines to read)
|
||||
# and if the process has finished (poll() returns exit code instead of None)
|
||||
if not line and process.poll() is not None:
|
||||
break
|
||||
|
||||
if line and line.startswith('out_time_ms='):
|
||||
try:
|
||||
time_str = line.split('=')[1].strip()
|
||||
current_time = float(time_str) / 1000000
|
||||
current_time = min(current_time, duration)
|
||||
progress = (current_time / duration) * 100
|
||||
elapsed = time.time() - start_time
|
||||
speed = current_time / elapsed if elapsed > 0 else 0
|
||||
if time.time() - last_update > UPDATE_INTERVAL:
|
||||
total_progress = (file_index / total_files) * 100
|
||||
sys.stdout.write(
|
||||
f"\rProcessing ({file_index}/{total_files} [{total_progress:.1f}%]) :"
|
||||
f"{progress:5.1f}% "
|
||||
f"({current_time:.1f} / {duration:.1f} seconds) "
|
||||
f"[{speed:.2f}x]"
|
||||
f"{'.' * int(progress / 5)}{' ' * (20 - int(progress / 5))}"
|
||||
)
|
||||
sys.stdout.flush()
|
||||
last_update = time.time()
|
||||
except:
|
||||
pass
|
||||
|
||||
def run(filepath, cmd, temp_path, file_index):
|
||||
duration = get_video_duration(filepath)
|
||||
xdg_state_home = os.environ.get('XDG_STATE_HOME', str(pathlib.Path.home() / '.local' / 'state'))
|
||||
ffmpeg_log_path = pathlib.Path(xdg_state_home) / 'vcmp' / 'ffmpeg.log'
|
||||
ffmpeg_log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(ffmpeg_log_path, 'a') as log_file:
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=log_file,
|
||||
universal_newlines=True
|
||||
)
|
||||
run_progress(process, duration, file_index)
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
if process.returncode != 0:
|
||||
logging.error(f"FFmpeg exited with code {process.returncode}")
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
sys.exit(1)
|
||||
|
||||
def process_single_file(filepath, target_path, temp_dir, remove_source, nice, file_index):
|
||||
temp_path = temp_dir / filepath.with_suffix(".mp4").name
|
||||
clean_directory(temp_dir) # Should be empty if mktemp was used, but clear it regardless.
|
||||
logging.info(f"Processing {filepath} to {target_path}")
|
||||
logging.debug(f"Using temporary file: {temp_path}")
|
||||
|
||||
cmd = ffmpeg_command(filepath, temp_path, nice)
|
||||
logging.debug(f"Executing: {' '.join(cmd)}")
|
||||
try:
|
||||
run(filepath, cmd, temp_path, file_index)
|
||||
except KeyboardInterrupt:
|
||||
logging.warning("Process interrupted by user")
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
sys.exit(1)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.error(f"FFmpeg error processing {filepath}: {e}")
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
sys.exit(1)
|
||||
else:
|
||||
# File succesfully converted to av1, move to destination
|
||||
# Use shutil move because of inter-fs issues
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(temp_path), str(target_path))
|
||||
logging.info(f"Successfully compressed {filepath}")
|
||||
if remove_source:
|
||||
logging.info(f"Removing source file {filepath}")
|
||||
filepath.unlink()
|
||||
|
||||
def process_directory(*, source_dir, destination_dir, temp_dir=None, remove_source=False, nice=20, count_only=False):
|
||||
"""Process all video files in source directory recursively."""
|
||||
if not count_only:
|
||||
if not temp_dir:
|
||||
raise ValueError("Temporary directory is required for processing files")
|
||||
count = 0
|
||||
for dirpath, _dirnames, filenames in os.walk(source_dir):
|
||||
for filename in filenames:
|
||||
filepath = pathlib.Path(dirpath) / filename
|
||||
|
||||
# File might have been deleted since starting script
|
||||
if not filepath.exists() or not is_video(filepath):
|
||||
logging.debug(f"Skipping {filepath}: not a video file or doesn't exist")
|
||||
continue
|
||||
if ".cmp." in filepath.name:
|
||||
logging.debug(f"Skipping {filepath}: already compressed")
|
||||
continue
|
||||
|
||||
# Determine destination path, remove source if already exists and requested
|
||||
target_path = destination_dir / filepath.relative_to(source_dir)
|
||||
target_path = target_path.with_suffix(".cmp" + target_path.suffix)
|
||||
if target_path.exists():
|
||||
if remove_source:
|
||||
logging.info(f"Removing source file {filepath} as compressed version exists")
|
||||
filepath.unlink()
|
||||
logging.debug(f"Skipping {filepath}: target already exists")
|
||||
continue
|
||||
|
||||
count += 1
|
||||
if count_only:
|
||||
continue
|
||||
|
||||
process_single_file(filepath, target_path, temp_dir, remove_source, nice, count)
|
||||
|
||||
logging.info(f"Found {count} files to process")
|
||||
return count
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="""
|
||||
Video compression utility that converts videos to AV1 format with reduced quality/size.
|
||||
Converts to 1080p max resolution, reduces high framerates, and uses low bitrate audio.
|
||||
The compressed output files will have '.cmp' added to the filename.
|
||||
"""
|
||||
)
|
||||
parser.add_argument('-s', '--source-dir', type=pathlib.Path, default=pathlib.Path.cwd(),
|
||||
help='Source directory containing videos to compress (default: current directory)')
|
||||
parser.add_argument('-d', '--destination-dir', type=pathlib.Path,
|
||||
help='Destination directory for compressed videos (default: same as source)')
|
||||
parser.add_argument('-t', '--temp-dir', type=pathlib.Path, default=pathlib.Path(tempfile.mkdtemp()),
|
||||
help='Temporary directory for processing (default: system temp directory)')
|
||||
parser.add_argument('-r', '--remove-source', action='store_true',
|
||||
help='Remove source files after successful compression')
|
||||
parser.add_argument('-v', '--verbose', action='store_true',
|
||||
help='Enable verbose logging output')
|
||||
parser.add_argument('-n', '--nice', type=lambda x: int(x) if 0 <= int(x) <= 39 else exec('raise ValueError("Nice value must be between 0 and 39")'),
|
||||
default=20,
|
||||
help='Nice value for process priority (default: 20). Valid values are 0 (highest priority) to 39 (lowest priority). Lower values use more system resources.')
|
||||
args = parser.parse_args()
|
||||
|
||||
setup_logging(args.verbose)
|
||||
|
||||
source_dir = args.source_dir
|
||||
destination_dir = args.destination_dir if args.destination_dir else args.source_dir
|
||||
temp_dir = args.temp_dir
|
||||
|
||||
logging.info(f"Starting video compression from {source_dir} to {destination_dir}")
|
||||
|
||||
global total_files
|
||||
total_files = process_directory(
|
||||
source_dir=source_dir,
|
||||
destination_dir=destination_dir,
|
||||
count_only=True
|
||||
)
|
||||
|
||||
process_directory(
|
||||
source_dir=source_dir,
|
||||
destination_dir=destination_dir,
|
||||
temp_dir=temp_dir,
|
||||
remove_source=args.remove_source,
|
||||
nice=args.nice
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user