Last active
July 4, 2025 00:49
-
-
Save BenMcLean/5165dc311b699bfe4e8b616ca668955f to your computer and use it in GitHub Desktop.
Organize Jellyfin movies into folders based on titles from .nfo files
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 | |
# 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/&/\&/g') | |
text=$(echo "$text" | sed 's/</</g') | |
text=$(echo "$text" | sed 's/>/>/g') | |
text=$(echo "$text" | sed 's/"/"/g') | |
text=$(echo "$text" | sed 's/'/'"'"'/g') | |
text=$(echo "$text" | sed 's/'/'"'"'/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