Skip to content

Instantly share code, notes, and snippets.

@brandonscript
Last active April 7, 2025 04:09
Show Gist options
  • Save brandonscript/65c38ac1fab7666e708817a4b85a93c4 to your computer and use it in GitHub Desktop.
Save brandonscript/65c38ac1fab7666e708817a4b85a93c4 to your computer and use it in GitHub Desktop.
Uses ffmpeg and mediainfo to recursively scan/determine bitrates for all video files in a given path
#!/bin/bash
# Trap ctrl+C (SIGINT) to exit functions mid-execution
trap "echo -e '\nBitrates was interrupted, exiting...'; exit 1" SIGINT
# Initialize variables
calc_enabled=false
organize_enabled=false
organize_test=false
delete_mode=false
include_resolution=false
directory=""
skip_profile=""
# Count the number of arguments that don't start with --
arg_count=0
for arg in "$@"; do
if [[ "$arg" != --* ]]; then
((arg_count++))
fi
done
print_help() {
echo "Calculates and saves video bitrate information for files in the specified directory as .txt files alongside any video files found."
echo ""
echo "Usage: $0 [--calc] [--del] [--res] [--skip=(x264|x265)] <directory>"
echo "Options:"
echo " <directory> Directory to search for video files (default: .)"
echo " --calc Calculate bitrate if not found"
echo " --del Delete files matching pattern '*-kbps.txt' and '_bitrates.txt'"
echo " --res Include resolution in the output filename"
echo " --organize Organize files into subdirectories based on their approx. bitrate and resolution"
echo " --organize-test Test the organization without moving files"
echo " --skip=(x264|x265) Skip files with specified codec"
}
# Parse arguments
for arg in "$@"; do
case "$arg" in
--help)
print_help
exit 0
;;
--calc)
calc_enabled=true
;;
--del)
delete_mode=true
;;
--res)
include_resolution=true
;;
--organize)
organize_enabled=true
;;
--organize-test)
organize_enabled=true
organize_test=true
;;
--skip=*)
skip_profile="${arg#--skip=}"
;;
*)
if [[ -z "$directory" ]]; then
directory="$arg"
elif [[ "$arg_count" -gt 1 ]]; then
echo "Error: Multiple directories provided. Only one is allowed."
exit 1
fi
;;
esac
done
if [[ -z "$directory" ]]; then
directory="."
fi
# Resolve the directory to an absolute path
print_directory=$(realpath "$directory" 2>/dev/null || echo "$directory")
if [[ ! -d "$directory" ]]; then
echo "Error: Directory '$print_directory' does not exist."
exit 1
fi
echo "Bitrates is analyzing '$print_directory'"
echo ""
if $delete_mode; then
echo "Deleting files matching pattern '*-kbps.txt' in '$print_directory'"
fi
find_cmd="find"
find_re_cmd="-regextype posix-egrep -regex"
infofile_regex=".*[0-9]{2,}-kbps\.txt"
infofile_sed="-([0-9]{3,4}[pv][_-])?[0-9]{2,}-?kbps"
orgdir_regex=".*[0-9]{3,4}[pv]_[0-9]{2,}kbps.*"
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS
find_cmd="find -E"
find_re_cmd="-regex"
orgdir_regex=".*[0-9]{3,4}[pv]_[0-9]{2,}kbps.*"
infofile_regex=".*[0-9]{2,}-kbps.txt"
fi
cleanup() {
$find_cmd "$directory" -type f $find_re_cmd "${infofile_regex}" -print0 | while IFS= read -r -d '' file; do
if $delete_mode; then
echo "Deleting '$file'"
fi
rm "$file"
done
# Delete _bitrates.txt files
find "$directory" -type f -name "_bitrates.txt" -print0 | while IFS= read -r -d '' file; do
if $delete_mode; then
echo "Deleting '$file'"
fi
rm "$file"
done
}
# cleanup
if $delete_mode; then
# If delete mode is enabled, only find and delete matching files then exit
exit 0
fi
# Function to check if a file matches the skip profile
should_skip_file() {
local file="$1"
local codec=""
# First-pass shortcut: check for the string in the filename
if [[ "$skip_profile" == "x264" && "$file" =~ x264 ]]; then
return 0
elif [[ "$skip_profile" == "x265" && ("$file" =~ x265 || "$file" =~ HEVC || "$file" =~ h265 || "$file" =~ h\.265) ]]; then
return 0
fi
# Use mediainfo to check the codec
codec=$(mediainfo --Output="Video;%Format%" "$file")
if [[ "$skip_profile" == "x264" && "$codec" == "AVC" ]]; then
return 0
elif [[ "$skip_profile" == "x265" && ("$codec" == "HEVC" || "$codec" == "x265") ]]; then
return 0
fi
return 1
}
get_filename_no_mediainfo() {
local file="$1"
local filename=$(basename -- "$file")
local dirpath=$(dirname -- "$file")
local filename_noext="${filename%.*}"
# Strip {resolution-?}{bitrate}-kbps from the filename
local filename_no_mediainfo=$(echo "$filename" | sed -E "s/${infofile_sed}//g" | sed 's/\.txt$//')
echo "$filename_no_mediainfo"
}
get_resolution() {
local file="$1"
local resolution=""
if $include_resolution; then
width=$(mediainfo --Output="Video;%Width%" "$file")
height=$(mediainfo --Output="Video;%Height%" "$file")
rotation=$(mediainfo --Output="Video;%Rotation%" "$file")
rotation=$(printf "%.0f" "${rotation:-0}")
orientation=""
# if w > h and rotation is 0 or 180, it's horizontal
if ((width > height)) && ((rotation == 0 || rotation == 180)); then
orientation="horizontal"
elif ((height > width)) || ((rotation == 90 || rotation == 270)); then
orientation="vertical"
fi
if [[ -n "$width" && -n "$height" && "$width" =~ ^[0-9]+$ && "$height" =~ ^[0-9]+$ ]]; then
if [[ "$orientation" == "vertical" ]]; then
# Vertical orientation
if ((height <= 480)); then
resolution="480v"
elif ((height <= 720)); then
resolution="720v"
elif ((height <= 1080)); then
resolution="1080v"
elif ((height <= 1440)); then
resolution="1440v"
elif ((height <= 2160)); then
resolution="2160v"
else
resolution="${height}v"
fi
else
# Horizontal orientation
if ((height <= 480)); then
resolution="480p"
elif ((height <= 720)); then
resolution="720p"
elif ((height <= 1080)); then
resolution="1080p"
elif ((height <= 1440)); then
resolution="1440p"
elif ((height <= 2160)); then
resolution="2160p"
else
resolution="${height}p"
fi
fi
fi
fi
echo "$resolution"
}
get_bitrate() {
local file="$1"
local bitrate_bps=""
# Get the bitrate using mediainfo (bps)
bitrate_bps=$(mediainfo --Output="Video;%BitRate%" "$file")
# If bitrate is empty or invalid, try calculating it with mkvpropedit
if [[ -z "$bitrate_bps" || "$bitrate_bps" == "N/A" || ! "$bitrate_bps" =~ ^[0-9]+$ ]]; then
if $calc_enabled; then
mkvpropedit "$file" --add-track-statistics-tags >/dev/null 2>&1
bitrate_bps=$(mediainfo --Output="Video;%BitRate%" "$file")
fi
fi
# If bitrate is still invalid, skip the file
if [[ -z "$bitrate_bps" || "$bitrate_bps" == "N/A" || ! "$bitrate_bps" =~ ^[0-9]+$ ]]; then
return
fi
# Convert to kbps (divide by 1000)
bitrate_kbps=$((bitrate_bps / 1000))
# return the bitrate
echo "$bitrate_kbps"
}
get_codec() {
local file="$1"
local codec=""
# Use mediainfo to check the codec
codec=$(mediainfo --Output="Video;%Format%" "$file")
# If codec is empty, skip the file
if [[ -z "$codec" ]]; then
return
fi
# return the codec
echo "$codec"
}
file_types=("*.mp4" "*.m4v" "*.mkv" "*.wmv" "*.avi" "*.mov" "*.webm")
make_find_cmd() {
local dir="$1"
# Create the find command with regex for video files
find_command="$find_cmd \"$dir\" -type f \("
for file_type in "${file_types[@]}"; do
find_command+=" -o -iname \"$file_type\""
done
find_command=${find_command/-o /}
find_command+=" \) ! ${find_re_cmd} '${orgdir_regex}'"
echo "$find_command"
}
get_media_info() {
# Find all video files recursively and process them
# file_types=("*.mp4" "*.m4v" "*.mkv" "*.wmv" "*.avi" "*.mov" "*.webm")
# find_command="$find_cmd \"$directory\" -type f \("
# for file_type in "${file_types[@]}"; do
# find_command+=" -o -iname \"$file_type\""
# done
# find_command=${find_command/-o /}
# find_command+=" \) ! ${find_re_cmd} '${orgdir_regex}'"
find_command=$(make_find_cmd "$directory")
eval $find_command -print0 | sort -z | while IFS= read -r -d '' file; do
# Check if the file should be skipped
dirname=$(dirname -- "$file")
basename=$(basename -- "$file")
if [[ -n "$skip_profile" ]] && should_skip_file "$file"; then
echo "Skipped '$file' ($skip_profile)"
continue
fi
# If file's path contains orgdir_regex, it's already organized and should be skipped
if [[ "$dirname" =~ $orgdir_regex ]]; then
echo "Skipped '$file' (Already organized)"
continue
fi
# Find all matching files in the directory and echo
if [[ $($find_cmd "$dirname" -maxdepth 1 -type f $find_re_cmd ".*/${basename%.*}-.*-kbps\.txt" | wc -l) -eq 1 ]]; then
# echo "Skipped '$file' (Already analyzed)"
continue
fi
# Get the bitrate using mediainfo (bps)
bitrate_kbps=$(get_bitrate "$file")
if [[ -z "$bitrate_kbps" || "$bitrate_kbps" == "N/A" || ! "$bitrate_kbps" =~ ^[0-9]+$ ]]; then
echo "Skipped '$file' (No valid bitrate found)"
continue
fi
# Extract the filename without extension
filename=$(basename -- "$file")
dirpath=$(dirname -- "$file")
filename_noext="${filename%.*}"
# Get resolution if --res is enabled
resolution=""
if $include_resolution; then
resolution=$(get_resolution "$file")
if [[ -z "$resolution" ]]; then
echo "Skipped '$file' (Invalid resolution)"
fi
fi
codec=$(get_codec "$file")
# Define the output text file name
output_file="$dirpath/${filename_noext}-${resolution}-${bitrate_kbps}-kbps.txt"
# Write bitrate info to the file
echo "Bitrate: ${bitrate_kbps} kbps" >"$output_file"
# Write resolution info to the file
if $include_resolution; then
echo "Resolution: $resolution" >>"$output_file"
fi
# Write codec info to the file
if [[ -n "$codec" ]]; then
echo "Codec: $codec" >>"$output_file"
fi
echo "Created '$output_file'"
done
}
get_media_info
echo "Done"
parse_bitrate_file() {
local file="$1"
local bitrate=""
# Extract the bitrate from the file content. Example:
# Bitrate: 2640 kbps
# Resolution: 720p
# Codec: AVC
file_content=$(<"$file")
bitrate=$(echo "$file_content" | awk -F': ' '/Bitrate:/ {print $2}' | awk '{print $1}')
resolution=$(echo "$file_content" | awk -F': ' '/Resolution:/ {print $2}')
codec=$(echo "$file_content" | awk -F': ' '/Codec:/ {print $2}')
# return the values, comma-separated
local result=""
if [[ -n "$bitrate" ]]; then
result+="$bitrate"
fi
if [[ -n "$resolution" ]]; then
result+="${result:+,}$resolution"
fi
if [[ -n "$codec" ]]; then
result+="${result:+,}$codec"
fi
echo "$result"
}
last_dirpath=""
organize_file() {
local txt_file="$1"
local filename=$(basename -- "$txt_file")
local dirpath=$(dirname -- "$txt_file")
# Strip {resolution-?}{bitrate}-kbps from the filename
local filename_no_mediainfo=$(get_filename_no_mediainfo "$txt_file")
local info=$(parse_bitrate_file "$txt_file")
# Extract the bitrate, resolution, and codec from the file content (via parse_bitrate_file)
local bitrate_kbps=$(echo "$info" | cut -d',' -f1)
local resolution=$(echo "$info" | cut -d',' -f2)
local codec=$(echo "$info" | cut -d',' -f3)
# If bitrate is empty or invalid, skip the file
if [[ -z "$bitrate_kbps" || "$bitrate_kbps" == "N/A" || ! "$bitrate_kbps" =~ ^[0-9]+$ ]]; then
return
fi
# Make all 'v' resolutions 'p' (e.g., 1080v -> 1080p)
local resolution=${resolution//[pv]/p}
local filename_noext="${filename%.*}"
# Determine the approximate bitrate range by rounding to the nearest sensible value
local bitrate_range=bitrate_kbps
if ((bitrate_kbps < 200)); then
bitrate_range=100
elif ((bitrate_kbps >= 200 && bitrate_kbps < 500)); then
bitrate_range=$((bitrate_kbps / 200 * 200))
elif ((bitrate_kbps >= 500 && bitrate_kbps < 8000)); then
bitrate_range=$((bitrate_kbps / 500 * 500))
elif ((bitrate_kbps >= 8000)); then
bitrate_range=$((bitrate_kbps / 1000 * 1000))
fi
# Generate the target directory name & path
local target_name="${resolution}_${bitrate_range}kbps"
local parent_dir=$(dirname "$dirpath")
local parent_name=$(basename "$dirpath")
local parent_parent_name=$(basename "$parent_dir")
# if dirpath is an organized dir, skip it
if [[ "$dirpath" =~ $orgdir_regex ]]; then
# echo "Skipped '$dirpath' (Is an organization dir)"
return
fi
# if parent matches new dir, it's already been organized
if [[ "$parent_name" == "$target_name" || "$parent_parent_name" == "$target_name" ]]; then
# echo "Skipped '$target_name' (Is $parent_name or $parent_parent_name)"
return
fi
local should_move_parent_dir=false
# If the file is the only video in the dir, check if it has a parent that can be moved instead
# (Parent cannot be root, and it cannot match the target directory's naming scheme)
if [[ "$dirpath" != "$directory" && $(find "$dirpath" -maxdepth 1 -type f -name "*-kbps.txt" | wc -l) -eq 1 ]]; then
should_move_parent_dir=true
fi
# if it contains any children that match the {resolution-?}{bitrate}-kbps pattern, skip it (we don't want to
# move a dir that already contains organized files)
if [[ "$dirpath" == "$directory" || $($find_cmd "$dirpath" -maxdepth 1 -type d $find_re_cmd "${orgdir_regex}" | wc -l) -gt 0 ]]; then
should_move_parent_dir=false
fi
# Find the corresponding video file in the target directory that matches filename_no_mediainfo
video_file=$(find "$dirpath" -maxdepth 1 -type f -iname "${filename_no_mediainfo}.*" ! -iname "*.txt" | head -n 1)
if [[ -n "$video_file" ]]; then
# echo "Found video file: $video_file"
# Generate the target directory path
if [[ $should_move_parent_dir == true ]]; then
# If the file is the only video in the dir, check if it has a parent that can be moved instead
target_dir="$parent_dir/$target_name"
target_path="$target_dir/$(basename "$dirpath")"
else
target_dir="$dirpath/$target_name"
target_path="$target_dir"
fi
# Check if the target directory already exists
if [[ -d "$target_path" && (-e "$target_path/$filename" || -e "$target_path/$filename_no_mediainfo") ]]; then
echo "Skipped '$video_file' (Already organized)"
return
else
# If the target directory doesn't exist, create it
if [[ $organize_test == false ]]; then
# echo "Creating directory '$target_dir'"
mkdir -p "$target_dir"
fi
if $should_move_parent_dir; then
if [[ $organize_test == false ]]; then
echo "Moved d '$dirpath' -- '$target_dir/'"
# Move the parent directory to the target directory
mv "$dirpath" "$target_dir"/
# sleep .2s
else
echo " T-mv d '$dirpath' -- '$target_dir/'"
fi
else
if [[ $organize_test == false ]]; then
echo "Moved f '$video_file' -- '$target_dir/'"
# Move the text file to the target directory with the new name
# Move the video file and the text file to the target directory
mv "$video_file" "$target_dir/"
mv "$txt_file" "$target_dir/$filename"
# sleep .2s
else
echo " T-mv f '$video_file' -- '$target_dir/'"
fi
fi
fi
fi
}
# If --organize is enabled, organize files into subdirectories
if $organize_enabled; then
echo ""
if $organize_test; then
echo "Organizing files (test mode)..."
else
echo "Organizing files..."
fi
find "$directory" -type f -name "*-kbps.txt" | while read -r txt_file; do
organize_file "$txt_file"
last_dirpath=$(dirname -- "$txt_file")
done
echo ""
echo "Done"
fi
# Calculates the lowest, highest, median, and average bitrate
calc_bitrate_summary() {
local dir="$1"
local bitrate_file="$dir/_bitrates.txt"
if [[ -f "$bitrate_file" ]]; then
rm "$bitrate_file"
fi
find "$dir" -type f -name "*-kbps.txt" | while read -r file; do
local info=$(parse_bitrate_file "$file")
local bitrate_kbps=$(echo "$info" | cut -d',' -f1)
echo "$bitrate_kbps" >>"$bitrate_file"
done
if [[ -s "$bitrate_file" ]]; then
line_count=$(wc -l <"$bitrate_file")
if [[ "$line_count" -gt 1 ]]; then
lowest=$(sort -n "$bitrate_file" | head -n 1)
highest=$(sort -n "$bitrate_file" | tail -n 1)
median=$(awk '{a[i++]=$0} END {print a[int(i/2)]}' "$bitrate_file")
average=$(awk '{sum+=$1} END {print sum/NR}' "$bitrate_file")
{
echo "Lowest: $lowest kbps"
echo "Highest: $highest kbps"
echo "Median: $median kbps"
echo "Average: $average kbps"
} >"$bitrate_file"
else
rm "$bitrate_file"
fi
# else
# echo "No video files found in '$dir'"
fi
if [[ "$dir" == "$directory" ]]; then
echo ""
if [[ -f "$bitrate_file" ]]; then
echo "Bitrate summary:"
cat "$bitrate_file"
# else
# echo "No bitrate summary available"
fi
fi
}
# Also produce a _bitrates.txt file in each subdirectory that has at least one video file
echo ""
echo "Calculating bitrate summaries..."
while read -r subdir; do
if [[ "$subdir" != "$directory" ]]; then
calc_bitrate_summary "$subdir"
fi
done < <(find "$directory" -type d)
calc_bitrate_summary "$directory"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment