#!/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