jellyfin-media/sort-uploads.sh

196 lines
6.6 KiB
Bash

#!/bin/bash
# Sort uploaded files from filebrowser into Jellyfin media directories
# - Merges into existing folders
# - Replaces files with higher quality versions
# - Runs on cron every 5 minutes
UPLOADS=/mnt/hetzner-media/uploads
MEDIA=/mnt/hetzner-media/media
LOG=/opt/media-server/sort-uploads.log
# Jellyfin API accessed via docker exec (not exposed on host)
# Load secrets from .env (source of truth: Infisical /media/JELLYFIN_API_KEY)
set -a; source /opt/media-server/.env; set +a
MOVED=0
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') $1" >> "$LOG"
}
# Minimum resolution: 1080p — reject anything below
MIN_RESOLUTION_SCORE=300
# Check if a file meets minimum quality (1080p+)
meets_min_quality() {
local name="$1"
case "$name" in
*2160p*|*4[Kk]*|*1080p*) return 0 ;;
*) return 1 ;;
esac
}
# Score a filename by quality indicators (higher = better)
quality_score() {
local name="$1"
local score=0
# Resolution
case "$name" in
*2160p*|*4[Kk]*) score=$((score + 400)) ;;
*1080p*) score=$((score + 300)) ;;
*720p*) score=$((score + 200)) ;;
*480p*) score=$((score + 100)) ;;
esac
# Source
case "$name" in
*[Bb]lu[Rr]ay*|*BDRip*|*BDREMUX*|*REMUX*) score=$((score + 50)) ;;
*WEB-DL*|*WEBDL*) score=$((score + 40)) ;;
*WEBRip*|*WEBRIP*) score=$((score + 30)) ;;
*HDTV*) score=$((score + 20)) ;;
*DVDRip*|*DVD*) score=$((score + 10)) ;;
esac
# Codec
case "$name" in
*[xX]265*|*HEVC*|*[hH]265*) score=$((score + 15)) ;;
*[xX]264*|*[hH]264*|*AVC*) score=$((score + 10)) ;;
esac
# Audio
case "$name" in
*[Aa]tmos*) score=$((score + 8)) ;;
*DTS-HD*|*TrueHD*) score=$((score + 7)) ;;
*DTS*) score=$((score + 6)) ;;
*EAC3*|*DDP*|*DD+*) score=$((score + 5)) ;;
*AC3*|*DD[^P]*) score=$((score + 4)) ;;
*AAC*) score=$((score + 3)) ;;
esac
# Bigger file is often better quality (use as tiebreaker via file size)
echo $score
}
# Merge a source item (file or directory) into a destination directory
# If destination has a matching item, compare quality for files or recurse for dirs
merge_item() {
local src="$1"
local dst_dir="$2"
local label="$3"
local name="$(basename "$src")"
local dst="$dst_dir/$name"
if [ -d "$src" ]; then
# Source is a directory
if [ -d "$dst" ]; then
# Both are directories - recurse and merge contents
log "MERGE [$label] $name/ (merging into existing directory)"
for child in "$src"/*; do
[ -e "$child" ] || continue
merge_item "$child" "$dst" "$label"
done
# Remove source dir if now empty
rmdir "$src" 2>/dev/null
elif [ -e "$dst" ]; then
# Destination exists but is a file, source is a dir - skip
log "CONFLICT [$label] $name (source is dir, dest is file - skipping)"
else
# Destination doesn't exist - move entire directory
mv "$src" "$dst_dir/"
if [ $? -eq 0 ]; then
log "MOVED [$label] $name/"
MOVED=$((MOVED + 1))
else
log "ERROR [$label] Failed to move $name/"
fi
fi
else
# Source is a file — enforce 1080p minimum for video files
case "$name" in
*.mkv|*.mp4|*.avi|*.m4v|*.ts|*.wmv)
if ! meets_min_quality "$name"; then
log "REJECTED [$label] $name (below 1080p minimum quality)"
rm -f "$src"
return
fi
;;
esac
if [ -f "$dst" ]; then
# Same filename exists - compare quality
local src_score=$(quality_score "$name")
local dst_score=$(quality_score "$(basename "$dst")")
# If scores are equal, compare file size (bigger often = better)
if [ "$src_score" -eq "$dst_score" ]; then
local src_size=$(stat -c%s "$src" 2>/dev/null || echo 0)
local dst_size=$(stat -c%s "$dst" 2>/dev/null || echo 0)
if [ "$src_size" -gt "$dst_size" ]; then
src_score=$((src_score + 1))
fi
fi
if [ "$src_score" -gt "$dst_score" ]; then
log "UPGRADE [$label] $name (score $src_score > $dst_score, replacing)"
mv "$src" "$dst"
MOVED=$((MOVED + 1))
else
log "KEEP [$label] $name (existing score $dst_score >= upload $src_score, discarding upload)"
rm -f "$src"
fi
elif [ -d "$dst" ]; then
# Destination is a directory but source is a file - skip
log "CONFLICT [$label] $name (source is file, dest is dir - skipping)"
else
# No conflict - just move
mv "$src" "$dst_dir/"
if [ $? -eq 0 ]; then
log "MOVED [$label] $name"
MOVED=$((MOVED + 1))
else
log "ERROR [$label] Failed to move $name"
fi
fi
fi
}
# Process a category folder
move_category() {
local src="$1"
local dst="$2"
local label="$3"
[ -d "$src" ] || return
[ -z "$(ls -A "$src" 2>/dev/null)" ] && return
for item in "$src"/*; do
[ -e "$item" ] || continue
merge_item "$item" "$dst" "$label"
done
}
# Move categorized uploads (case-insensitive folder names)
move_category "$UPLOADS/Movies" "$MEDIA/movies" "movies"
move_category "$UPLOADS/movies" "$MEDIA/movies" "movies"
move_category "$UPLOADS/Shows" "$MEDIA/shows" "shows"
move_category "$UPLOADS/shows" "$MEDIA/shows" "shows"
move_category "$UPLOADS/Music" "$MEDIA/music" "music"
move_category "$UPLOADS/music" "$MEDIA/music" "music"
# Log any unsorted items at root level
for item in "$UPLOADS"/*; do
[ -e "$item" ] || continue
name="$(basename "$item")"
case "$name" in
Movies|movies|Shows|shows|Music|music) continue ;;
esac
log "UNSORTED $name - move to Movies/, Shows/, or Music/ subfolder"
done
# Trigger Jellyfin library scan if files were moved
if [ $MOVED -gt 0 ]; then
log "Triggering Jellyfin library scan ($MOVED items moved)"
docker exec jellyfin curl -sf -X POST "http://localhost:8096/Library/Refresh" \
-H "X-Emby-Token: $JELLYFIN_API_KEY" > /dev/null 2>&1
fi