Last active
April 7, 2025 04:09
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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