Skip to content

Instantly share code, notes, and snippets.

@BenMcLean
Last active July 4, 2025 00:49
Show Gist options
  • Save BenMcLean/5165dc311b699bfe4e8b616ca668955f to your computer and use it in GitHub Desktop.
Save BenMcLean/5165dc311b699bfe4e8b616ca668955f to your computer and use it in GitHub Desktop.
Organize Jellyfin movies into folders based on titles from .nfo files
#!/bin/bash
# Jellyfin Movie Organizer Script
# This script organizes movie files into folders based on titles from .nfo files
# Usage: ./organize_movies.sh [--dry-run] /path/to/your/movie/library
set -e
# Check for xmlstarlet dependency
if ! command -v xmlstarlet >/dev/null 2>&1; then
echo "Error: 'xmlstarlet' is required but not installed."
echo "Please install it on Ubuntu with:"
echo " sudo apt update"
echo " sudo apt install xmlstarlet"
exit 1
fi
# Parse command line arguments
DRY_RUN=false
MOVIE_DIR=""
while [[ $# -gt 0 ]]; do
case $1 in
--dry-run)
DRY_RUN=true
shift
;;
-*)
echo "Unknown option $1"
echo "Usage: $0 [--dry-run] <movie_directory>"
echo " --dry-run Preview changes without making them"
echo "Example: $0 --dry-run /home/user/jellyfin/movies"
exit 1
;;
*)
if [[ -z "$MOVIE_DIR" ]]; then
MOVIE_DIR="$1"
else
echo "Error: Multiple directories specified"
echo "Usage: $0 [--dry-run] <movie_directory>"
exit 1
fi
shift
;;
esac
done
# Check if directory parameter is provided
if [[ -z "$MOVIE_DIR" ]]; then
echo "Usage: $0 [--dry-run] <movie_directory>"
echo " --dry-run Preview changes without making them"
echo "Example: $0 /home/user/jellyfin/movies"
echo "Example: $0 --dry-run /home/user/jellyfin/movies"
exit 1
fi
# Check if directory exists
if [ ! -d "$MOVIE_DIR" ]; then
echo "Error: Directory '$MOVIE_DIR' does not exist"
exit 1
fi
# Change to the movie directory
cd "$MOVIE_DIR" || { echo "Error: Cannot access directory $MOVIE_DIR"; exit 1; }
echo "Starting movie organization in: $MOVIE_DIR"
if [[ "$DRY_RUN" == true ]]; then
echo "*** DRY RUN MODE - NO CHANGES WILL BE MADE ***"
fi
echo "----------------------------------------"
# Check the ownership and permissions of the movie directory
MOVIE_DIR_STAT=$(stat -c "%U:%G %a" "$MOVIE_DIR")
MOVIE_DIR_OWNER=$(stat -c "%U:%G" "$MOVIE_DIR")
MOVIE_DIR_PERMS=$(stat -c "%a" "$MOVIE_DIR")
echo "Movie directory ownership: $MOVIE_DIR_OWNER"
echo "Movie directory permissions: $MOVIE_DIR_PERMS"
# Check if we're running as the same user as the directory owner
CURRENT_USER=$(whoami)
DIR_USER=$(stat -c "%U" "$MOVIE_DIR")
if [[ "$CURRENT_USER" != "$DIR_USER" && "$CURRENT_USER" != "root" ]]; then
echo "WARNING: You're running as '$CURRENT_USER' but directory is owned by '$DIR_USER'"
echo "You may need to run as: sudo -u $DIR_USER $0 $1"
echo "Or run as root: sudo $0 $1"
echo ""
read -p "Continue anyway? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 1
fi
fi
# Function to decode HTML entities
decode_html_entities() {
local text="$1"
# Decode common HTML entities
text=$(echo "$text" | sed 's/&amp;/\&/g')
text=$(echo "$text" | sed 's/&lt;/</g')
text=$(echo "$text" | sed 's/&gt;/>/g')
text=$(echo "$text" | sed 's/&quot;/"/g')
text=$(echo "$text" | sed 's/&#39;/'"'"'/g')
text=$(echo "$text" | sed 's/&apos;/'"'"'/g')
echo "$text"
}
# Function to sanitize folder names
sanitize_folder_name() {
local name="$1"
# Decode HTML entities first
name=$(decode_html_entities "$name")
# Remove leading "The " for better alphabetical sorting
if [[ "$name" =~ ^"The " ]]; then
name="${name#The }"
fi
# Replace invalid characters with " - " (space-dash-space)
# Invalid characters for filenames: < > : " / \ | ? *
# Note: & is NOT an invalid character and should be preserved
name=$(echo "$name" | sed 's/[<>:"/\\|?*]/ - /g')
# Replace multiple consecutive " - " with single " - "
name=$(echo "$name" | sed 's/ - \+/ - /g')
# Trim leading and trailing spaces and dashes
name=$(echo "$name" | sed 's/^[ -]*//;s/[ -]*$//')
echo "$name"
}
# Extract title from root-level tags in priority order: <sorttitle>, <title>, <originaltitle>, <name>
extract_title_from_nfo() {
local nfo_file="$1"
local title
title=$(xmlstarlet sel -t -v "/*/sorttitle" "$nfo_file" 2>/dev/null)
if [[ -n "$title" ]]; then echo "$title"; return; fi
title=$(xmlstarlet sel -t -v "/*/title" "$nfo_file" 2>/dev/null)
if [[ -n "$title" ]]; then echo "$title"; return; fi
title=$(xmlstarlet sel -t -v "/*/originaltitle" "$nfo_file" 2>/dev/null)
if [[ -n "$title" ]]; then echo "$title"; return; fi
title=$(xmlstarlet sel -t -v "/*/name" "$nfo_file" 2>/dev/null)
if [[ -n "$title" ]]; then echo "$title"; return; fi
echo ""
}
# Extract 4-digit year from root-level <year> or <premiered>/<releasedate> using xmlstarlet
extract_year_from_nfo() {
local nfo_file="$1"
local year=""
year=$(xmlstarlet sel -t -v "/*/year" "$nfo_file" 2>/dev/null)
if [[ "$year" =~ ^[0-9]{4}$ ]]; then echo "$year"; return; fi
year=$(xmlstarlet sel -t -v "/*/premiered" "$nfo_file" 2>/dev/null | cut -c1-4)
if [[ "$year" =~ ^[0-9]{4}$ ]]; then echo "$year"; return; fi
year=$(xmlstarlet sel -t -v "/*/releasedate" "$nfo_file" 2>/dev/null | cut -c1-4)
if [[ "$year" =~ ^[0-9]{4}$ ]]; then echo "$year"; return; fi
echo ""
}
# Check for subtitle file extensions
is_subtitle_file() {
local file="$1"
local extension="${file##*.}"
extension=$(echo "$extension" | tr '[:upper:]' '[:lower:]')
case "$extension" in
srt|sub|idx|ass|ssa|vtt|smi|rt|txt)
return 0 ;;
*)
return 1 ;;
esac
}
# First pass: validate all movies have .nfo files and extractable titles
echo "Validating .nfo files..."
declare -a MOVIES_TO_PROCESS=()
for movie_file in *.mp4 *.mkv; do
# Skip if no files match the pattern
[[ ! -f "$movie_file" ]] && continue
# Extract the base name without extension
base_name="${movie_file%.*}"
nfo_file="$base_name.nfo"
# Check if .nfo file exists
if [[ ! -f "$nfo_file" ]]; then
echo "SKIP: Missing .nfo file for: $movie_file"
continue
fi
# Validate XML file, skip if invalid
if ! xmlstarlet val "$nfo_file" >/dev/null 2>&1; then
echo "SKIP: Invalid XML in $nfo_file"
continue
fi
# Check if we can extract a title
title=$(extract_title_from_nfo "$nfo_file")
if [[ -z "$title" ]]; then
echo "SKIP: Cannot extract title from: $nfo_file"
continue
fi
# Check if we can extract a year
year=$(extract_year_from_nfo "$nfo_file")
if [[ -z "$year" ]]; then
echo "SKIP: Cannot extract year from: $nfo_file"
continue
fi
# Create folder name and sanitize it
folder_name="$title ($year)"
folder_name=$(sanitize_folder_name "$folder_name")
# Store movie info for processing
MOVIES_TO_PROCESS+=("$movie_file|$base_name|$title|$year|$folder_name")
echo "✓ $movie_file -> \"$title ($year)\""
done
if [[ "${#MOVIES_TO_PROCESS[@]}" -eq 0 ]]; then
echo "No valid movies to process. Exiting."
exit 0
fi
echo ""
if [[ "$DRY_RUN" == true ]]; then
echo "All validations passed. Showing preview of changes..."
else
echo "All validations passed. Starting organization..."
fi
echo "----------------------------------------"
# Second pass: organize the files
for movie_info in "${MOVIES_TO_PROCESS[@]}"; do
# Parse the stored movie information
IFS='|' read -r movie_file base_name title year folder_name <<< "$movie_info"
nfo_file="$base_name.nfo"
echo "Processing: $base_name"
echo " Title: \"$title ($year)\""
echo " Folder: \"$folder_name\""
# Create directory with the sanitized movie title
if [[ "$DRY_RUN" != true ]]; then
mkdir -p "$folder_name"
# Set ownership and permissions to match the parent directory
chown --reference="$MOVIE_DIR" "$folder_name" 2>/dev/null || echo " Note: Could not set ownership (may need sudo)"
chmod --reference="$MOVIE_DIR" "$folder_name" 2>/dev/null || echo " Note: Could not set permissions (may need sudo)"
fi
# Move all files with the pattern [base_name].* (excluding video and .nfo) to the new folder, keeping the original name.
for dot_file in "$base_name".*; do
[[ ! -f "$dot_file" ]] && continue
ext="${dot_file##*.}"
case "$ext" in
mp4|mkv|nfo) continue ;; # skip main video and nfo
esac
if [[ "$DRY_RUN" != true ]]; then
mv "$dot_file" "$folder_name/"
fi
echo " Moved: $dot_file -> $folder_name/ (dot file - original name preserved)"
done
# Move the movie file (keep original filename)
if [[ -f "$movie_file" ]]; then
if [[ "$DRY_RUN" != true ]]; then
mv "$movie_file" "$folder_name/"
fi
echo " Moved: $movie_file -> $folder_name/"
fi
# Handle the main .nfo file (rename to movie.nfo)
if [[ -f "$nfo_file" ]]; then
if [[ "$DRY_RUN" != true ]]; then
mv "$nfo_file" "$folder_name/movie.nfo"
fi
echo " Moved: $nfo_file -> $folder_name/movie.nfo"
fi
# Handle all other files that start with the base name and a dash [base_name]-*
for other_file in "$base_name"-*; do
# Skip if it's a directory or doesn't exist
[[ ! -f "$other_file" ]] && continue
# Get the extension part after the base name
# This handles cases like "filename-backdrop.jpg" correctly
extension="${other_file}"
extension="${extension#$base_name-}" # Remove "basename-" from the front
# Special case: .poster.jpg -> folder.jpg
if [[ "$extension" == "poster.jpg" ]]; then
if [[ "$DRY_RUN" != true ]]; then
mv "$other_file" "$folder_name/folder.jpg"
fi
echo " Moved: $other_file -> $folder_name/folder.jpg"
# Special case: subtitle files (keep original filename)
elif is_subtitle_file "$other_file"; then
if [[ "$DRY_RUN" != true ]]; then
mv "$other_file" "$folder_name/"
fi
echo " Moved: $other_file -> $folder_name/ (subtitle file - no rename)"
# General case: everything else gets the base name prefix removed
else
if [[ "$DRY_RUN" != true ]]; then
mv "$other_file" "$folder_name/$extension"
fi
echo " Moved: $other_file -> $folder_name/$extension"
fi
done
echo " ✓ Completed: $folder_name"
echo ""
done
echo "Movie organization complete!"
echo "----------------------------------------"
# Show a summary of created directories
echo "Created directories:"
for movie_info in "${MOVIES_TO_PROCESS[@]}"; do
IFS='|' read -r movie_file base_name title year folder_name <<< "$movie_info"
echo " - $folder_name/"
done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment