Skip to content

Instantly share code, notes, and snippets.

@benlacey57
Last active November 24, 2025 01:09
Show Gist options
  • Select an option

  • Save benlacey57/2c721ec9ae23f59026183cb254b7dc69 to your computer and use it in GitHub Desktop.

Select an option

Save benlacey57/2c721ec9ae23f59026183cb254b7dc69 to your computer and use it in GitHub Desktop.
Shell script to copy TV series and movies into Jellyfin at random.
#!/bin/bash
#############################################################################
# Media File Organisation Script
# Purpose: Randomly copy movies, TV shows, and Christmas content from
# multiple media drives to organised destination folders
#############################################################################
set -euo pipefail
IFS=$'\n\t'
#############################################################################
# CONFIGURATION
#############################################################################
readonly SOURCE_PATHS=(
"/media/devmon/sdb1-ata-Samsung"*"/Media"
"/media/devmon/Data (Spare)/Media"
)
readonly DEST_MOVIES="/DATA/Media/Movies"
readonly DEST_SHOWS="/DATA/Media/Shows"
readonly DEST_CHRISTMAS="/DATA/Media/Christmas"
readonly NUM_RANDOM_MOVIES=10
readonly NUM_TV_SHOWS=5
readonly NUM_EPISODES_PER_SHOW=5
# Fixed rsync options as array
readonly RSYNC_OPTS=(
"-a" # Archive mode
"-v" # Verbose
"-h" # Human readable
"--progress" # Show progress
"--ignore-existing" # Skip existing files
)
readonly LOG_FILE="/tmp/media_copy_$(date +%Y%m%d_%H%M%S).log"
#############################################################################
# COLOUR CODES
#############################################################################
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[1;33m'
readonly BLUE='\033[0;34m'
readonly CYAN='\033[0;36m'
readonly NC='\033[0m'
#############################################################################
# LOGGING FUNCTIONS
#############################################################################
log() {
local level="$1"
shift
local message="$*"
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo -e "${timestamp} [${level}] ${message}" | tee -a "$LOG_FILE"
}
log_info() {
echo -e "${BLUE}[INFO]${NC} $*"
log "INFO" "$*"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $*"
log "SUCCESS" "$*"
}
log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $*"
log "WARNING" "$*"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $*" >&2
log "ERROR" "$*"
}
log_skip() {
echo -e "${CYAN}[SKIP]${NC} $*"
log "SKIP" "$*"
}
error_exit() {
log_error "$1"
exit 1
}
#############################################################################
# VALIDATION FUNCTIONS
#############################################################################
validate_path() {
local path="$1"
local path_type="$2"
if [[ "$path" =~ \.\. ]]; then
log_error "Path traversal detected in: $path"
return 1
fi
if [[ ! -e "$path" ]]; then
log_error "$path_type path does not exist: $path"
return 1
fi
return 0
}
check_disk_space() {
local dest_path="$1"
local required_mb="${2:-1000}"
local available_mb
available_mb=$(df -BM "$dest_path" | awk 'NR==2 {print $4}' | sed 's/M//')
if [[ "$available_mb" -lt "$required_mb" ]]; then
log_error "Insufficient disk space. Available: ${available_mb}MB, Required: ${required_mb}MB"
return 1
fi
log_info "Disk space check passed: ${available_mb}MB available"
return 0
}
create_directory() {
local dir="$1"
if [[ ! -d "$dir" ]]; then
if mkdir -p "$dir" 2>/dev/null; then
log_success "Created directory: $dir"
else
log_error "Failed to create directory: $dir"
return 1
fi
fi
return 0
}
#############################################################################
# FILENAME NORMALISATION
#############################################################################
normalise_filename() {
local filename="$1"
local extension="${filename##*.}"
local basename="${filename%.*}"
# Remove special characters and normalise spacing
basename=$(echo "$basename" | \
sed 's/[^a-zA-Z0-9._-]/ /g' | \
sed 's/ */ /g' | \
sed 's/^ *//;s/ *$//' | \
tr ' ' '.')
# Remove multiple dots
basename=$(echo "$basename" | sed 's/\.\.*/./g' | sed 's/^\.//' | sed 's/\.$//')
# Convert to lowercase for consistency
basename=$(echo "$basename" | tr '[:upper:]' '[:lower:]')
extension=$(echo "$extension" | tr '[:upper:]' '[:lower:]')
echo "${basename}.${extension}"
}
file_exists_in_dest() {
local source_file="$1"
local dest_dir="$2"
local filename
filename=$(basename "$source_file")
local normalised
normalised=$(normalise_filename "$filename")
local dest_path="$dest_dir/$normalised"
if [[ -f "$dest_path" ]]; then
return 0 # File exists
fi
return 1 # File does not exist
}
#############################################################################
# FILE DISCOVERY FUNCTIONS
#############################################################################
expand_source_paths() {
local -a expanded_paths=()
for pattern in "${SOURCE_PATHS[@]}"; do
for path in $pattern; do
if [[ -d "$path" ]]; then
expanded_paths+=("$path")
log_info "Found source path: $path"
fi
done
done
if [[ ${#expanded_paths[@]} -eq 0 ]]; then
error_exit "No valid source paths found"
fi
printf '%s\n' "${expanded_paths[@]}"
}
#############################################################################
# MOVIE COPYING FUNCTIONS
#############################################################################
copy_random_movies() {
log_info "========================================"
log_info "COPYING RANDOM MOVIES"
log_info "========================================"
create_directory "$DEST_MOVIES" || return 1
local -a all_movies=()
local -a source_paths
mapfile -t source_paths < <(expand_source_paths)
# Collect all video files from all sources
for source in "${source_paths[@]}"; do
while IFS= read -r -d '' file; do
# Exclude TV show patterns and Christmas folders
if [[ ! "$file" =~ [Ss][0-9]{2}[Ee][0-9]{2} ]] && \
[[ ! "$file" =~ [0-9]{1,2}x[0-9]{1,2} ]] && \
[[ ! "$file" =~ [Cc]hristmas ]] && \
[[ ! "$(basename "$(dirname "$file")")" =~ ^(Season|Series|TV|Shows?) ]]; then
all_movies+=("$file")
fi
done < <(find "$source" -type f \( \
-iname "*.avi" -o -iname "*.mkv" -o -iname "*.mp4" -o \
-iname "*.m4v" -o -iname "*.mov" -o -iname "*.wmv" -o \
-iname "*.flv" -o -iname "*.webm" -o -iname "*.mpeg" -o \
-iname "*.mpg" -o -iname "*.m2ts" -o -iname "*.ts" \
\) -print0 2>/dev/null)
done
local total_movies=${#all_movies[@]}
log_info "Found $total_movies potential movie files"
if [[ $total_movies -eq 0 ]]; then
log_warning "No movie files found"
return 0
fi
# Randomly select movies
local num_to_copy=$NUM_RANDOM_MOVIES
if [[ $total_movies -lt $num_to_copy ]]; then
num_to_copy=$total_movies
fi
log_info "Selecting $num_to_copy random movies..."
# Shuffle and select
local -a selected_movies
mapfile -t selected_movies < <(printf '%s\n' "${all_movies[@]}" | shuf -n "$num_to_copy")
# Display selected movies
echo ""
log_info "SELECTED MOVIES:"
log_info "----------------"
local count=1
for movie in "${selected_movies[@]}"; do
local filename
filename=$(basename "$movie")
local normalised
normalised=$(normalise_filename "$filename")
printf "${CYAN}%2d.${NC} %s\n" "$count" "$normalised"
printf " Source: %s\n" "$movie"
((count++))
done
echo ""
local copied=0
local failed=0
local skipped=0
for movie in "${selected_movies[@]}"; do
local filename
filename=$(basename "$movie")
local normalised
normalised=$(normalise_filename "$filename")
local dest="$DEST_MOVIES/$normalised"
# Check if file already exists
if [[ -f "$dest" ]]; then
log_skip "Already exists: $normalised"
((skipped++))
continue
fi
log_info "Copying: $normalised"
if rsync "${RSYNC_OPTS[@]}" "$movie" "$dest" 2>&1 | tee -a "$LOG_FILE"; then
((copied++))
log_success "Copied: $normalised"
else
((failed++))
log_error "Failed to copy: $normalised"
fi
done
log_info "Movies - Copied: $copied, Skipped: $skipped, Failed: $failed"
}
#############################################################################
# TV SHOW FUNCTIONS
#############################################################################
extract_show_name() {
local filepath="$1"
local filename
filename=$(basename "$filepath")
# Pattern 1: Show.Name.S01E01
if [[ "$filename" =~ ^(.+)[._\ ][Ss][0-9]{2}[Ee][0-9]{2} ]]; then
echo "${BASH_REMATCH[1]}" | tr '._' ' ' | sed 's/ */ /g' | sed 's/^ *//;s/ *$//'
return 0
fi
# Pattern 2: Show.Name.1x01
if [[ "$filename" =~ ^(.+)[._\ ][0-9]{1,2}x[0-9]{1,2} ]]; then
echo "${BASH_REMATCH[1]}" | tr '._' ' ' | sed 's/ */ /g' | sed 's/^ *//;s/ *$//'
return 0
fi
# Pattern 3: From parent directory
local parent_dir
parent_dir=$(basename "$(dirname "$filepath")")
if [[ "$parent_dir" =~ ^(Season|Series) ]]; then
local grandparent_dir
grandparent_dir=$(basename "$(dirname "$(dirname "$filepath")")")
echo "$grandparent_dir" | tr '._' ' ' | sed 's/ */ /g' | sed 's/^ *//;s/ *$//'
return 0
fi
return 1
}
normalise_show_name() {
local show_name="$1"
# Replace spaces with dots, remove special characters
show_name=$(echo "$show_name" | \
sed 's/[^a-zA-Z0-9 ]//g' | \
sed 's/ */ /g' | \
sed 's/^ *//;s/ *$//' | \
tr ' ' '.')
# Convert to title case
show_name=$(echo "$show_name" | sed 's/\b\(.\)/\u\1/g')
echo "$show_name"
}
find_tv_episodes() {
local -a source_paths
mapfile -t source_paths < <(expand_source_paths)
local -a all_episodes=()
for source in "${source_paths[@]}"; do
while IFS= read -r -d '' file; do
if [[ "$file" =~ [Ss][0-9]{2}[Ee][0-9]{2} ]] || \
[[ "$file" =~ [0-9]{1,2}x[0-9]{1,2} ]]; then
all_episodes+=("$file")
fi
done < <(find "$source" -type f \( \
-iname "*[Ss][0-9][0-9][Ee][0-9][0-9]*" -o \
-iname "*[0-9]x[0-9][0-9]*" \
\) -print0 2>/dev/null)
done
printf '%s\n' "${all_episodes[@]}"
}
copy_tv_shows() {
log_info "========================================"
log_info "COPYING TV SHOW EPISODES"
log_info "========================================"
create_directory "$DEST_SHOWS" || return 1
# Find all TV episodes
local -a all_episodes
mapfile -t all_episodes < <(find_tv_episodes)
local total_episodes=${#all_episodes[@]}
log_info "Found $total_episodes TV show episodes"
if [[ $total_episodes -eq 0 ]]; then
log_warning "No TV show episodes found"
return 0
fi
# Group episodes by show name
declare -A shows_episodes
for episode in "${all_episodes[@]}"; do
local show_name
if show_name=$(extract_show_name "$episode"); then
shows_episodes["$show_name"]+="$episode"$'\n'
fi
done
log_info "Found ${#shows_episodes[@]} unique TV shows"
# Select random shows
local -a show_names=("${!shows_episodes[@]}")
local num_shows=$NUM_TV_SHOWS
if [[ ${#show_names[@]} -lt $num_shows ]]; then
num_shows=${#show_names[@]}
fi
local -a selected_shows
mapfile -t selected_shows < <(printf '%s\n' "${show_names[@]}" | shuf -n "$num_shows")
# Display selected shows
echo ""
log_info "SELECTED TV SHOWS:"
log_info "------------------"
local count=1
for show_name in "${selected_shows[@]}"; do
local normalised_show
normalised_show=$(normalise_show_name "$show_name")
printf "${CYAN}%d.${NC} %s\n" "$count" "$normalised_show"
((count++))
done
echo ""
local total_copied=0
local total_skipped=0
local total_failed=0
for show_name in "${selected_shows[@]}"; do
local normalised_show
normalised_show=$(normalise_show_name "$show_name")
log_info "Processing show: $normalised_show"
# Create show directory
local show_dir="$DEST_SHOWS/$normalised_show"
create_directory "$show_dir" || continue
# Get episodes for this show
local -a show_episodes_array
mapfile -t show_episodes_array < <(echo -n "${shows_episodes[$show_name]}" | head -n -1)
# Select random episodes
local num_eps=$NUM_EPISODES_PER_SHOW
if [[ ${#show_episodes_array[@]} -lt $num_eps ]]; then
num_eps=${#show_episodes_array[@]}
fi
local -a selected_episodes
mapfile -t selected_episodes < <(printf '%s\n' "${show_episodes_array[@]}" | shuf -n "$num_eps")
# Display selected episodes
log_info " Selected episodes:"
for episode in "${selected_episodes[@]}"; do
local filename
filename=$(basename "$episode")
local normalised
normalised=$(normalise_filename "$filename")
printf " - %s\n" "$normalised"
done
echo ""
# Copy episodes
for episode in "${selected_episodes[@]}"; do
local filename
filename=$(basename "$episode")
local normalised
normalised=$(normalise_filename "$filename")
local dest="$show_dir/$normalised"
# Check if file already exists
if [[ -f "$dest" ]]; then
log_skip " Already exists: $normalised"
((total_skipped++))
continue
fi
log_info " Copying: $normalised"
if rsync "${RSYNC_OPTS[@]}" "$episode" "$dest" 2>&1 | tee -a "$LOG_FILE"; then
((total_copied++))
log_success " Copied: $normalised"
else
((total_failed++))
log_error " Failed to copy: $normalised"
fi
done
echo ""
done
log_info "TV Episodes - Copied: $total_copied, Skipped: $total_skipped, Failed: $total_failed"
}
#############################################################################
# CHRISTMAS MOVIES FUNCTION
#############################################################################
copy_christmas_movies() {
log_info "========================================"
log_info "COPYING CHRISTMAS MOVIES"
log_info "========================================"
create_directory "$DEST_CHRISTMAS" || return 1
local -a source_paths
mapfile -t source_paths < <(expand_source_paths)
local -a christmas_files=()
local found_christmas_folder=false
# Search for Christmas folders
for source in "${source_paths[@]}"; do
while IFS= read -r -d '' christmas_dir; do
found_christmas_folder=true
log_info "Found Christmas folder: $christmas_dir"
# Find all video files in Christmas folder
while IFS= read -r -d '' file; do
christmas_files+=("$file")
done < <(find "$christmas_dir" -type f \( \
-iname "*.avi" -o -iname "*.mkv" -o -iname "*.mp4" -o \
-iname "*.m4v" -o -iname "*.mov" -o -iname "*.wmv" -o \
-iname "*.flv" -o -iname "*.webm" -o -iname "*.mpeg" -o \
-iname "*.mpg" -o -iname "*.m2ts" -o -iname "*.ts" \
\) -print0 2>/dev/null)
done < <(find "$source" -type d -iname "*christmas*" -print0 2>/dev/null)
done
if [[ "$found_christmas_folder" == false ]]; then
log_warning "No Christmas folders found"
return 0
fi
local total_files=${#christmas_files[@]}
log_info "Found $total_files Christmas video files"
if [[ $total_files -eq 0 ]]; then
log_warning "No Christmas video files found"
return 0
fi
# Display selected Christmas movies
echo ""
log_info "SELECTED CHRISTMAS MOVIES:"
log_info "--------------------------"
local count=1
for file in "${christmas_files[@]}"; do
local filename
filename=$(basename "$file")
local normalised
normalised=$(normalise_filename "$filename")
printf "${CYAN}%2d.${NC} %s\n" "$count" "$normalised"
((count++))
done
echo ""
local copied=0
local failed=0
local skipped=0
for file in "${christmas_files[@]}"; do
local filename
filename=$(basename "$file")
local normalised
normalised=$(normalise_filename "$filename")
local dest="$DEST_CHRISTMAS/$normalised"
# Check if file already exists
if [[ -f "$dest" ]]; then
log_skip "Already exists: $normalised"
((skipped++))
continue
fi
log_info "Copying: $normalised"
if rsync "${RSYNC_OPTS[@]}" "$file" "$dest" 2>&1 | tee -a "$LOG_FILE"; then
((copied++))
log_success "Copied: $normalised"
else
((failed++))
log_error "Failed to copy: $normalised"
fi
done
log_info "Christmas Movies - Copied: $copied, Skipped: $skipped, Failed: $failed"
}
#############################################################################
# MAIN EXECUTION
#############################################################################
main() {
log_info "========================================"
log_info "MEDIA FILE ORGANISATION SCRIPT"
log_info "Started at: $(date)"
log_info "Log file: $LOG_FILE"
log_info "========================================"
# Validate destination paths and create if needed
for dest in "$DEST_MOVIES" "$DEST_SHOWS" "$DEST_CHRISTMAS"; do
create_directory "$dest" || error_exit "Failed to create destination: $dest"
done
# Check disk space
check_disk_space "/DATA" 5000 || error_exit "Insufficient disk space"
# Execute copying operations
copy_random_movies
echo ""
copy_tv_shows
echo ""
copy_christmas_movies
echo ""
log_info "========================================"
log_info "SCRIPT COMPLETED"
log_info "Finished at: $(date)"
log_info "Check log file for details: $LOG_FILE"
log_info "========================================"
}
# Trap errors and interrupts
trap 'log_error "Script interrupted or failed at line $LINENO"' ERR INT TERM
# Run main function
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment