Last active
November 24, 2025 01:09
-
-
Save benlacey57/2c721ec9ae23f59026183cb254b7dc69 to your computer and use it in GitHub Desktop.
Shell script to copy TV series and movies into Jellyfin at random.
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 | |
| ############################################################################# | |
| # 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