196 lines
6.6 KiB
Bash
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
|