diff --git a/docker-compose-server.yml b/docker-compose-server.yml index 4311bf6..81a3769 100644 --- a/docker-compose-server.yml +++ b/docker-compose-server.yml @@ -141,6 +141,29 @@ services: - "traefik.http.services.prowlarr.loadbalancer.server.port=9696" - "traefik.docker.network=traefik-public" + # Subtitle Management - Auto-downloads subtitles for all Radarr/Sonarr content + bazarr: + image: linuxserver/bazarr:latest + container_name: bazarr + restart: unless-stopped + environment: + - PUID=1000 + - PGID=1000 + - TZ=Europe/Berlin + volumes: + - ./config/bazarr:/config + - /mnt/hetzner-media/media/movies:/movies + - /mnt/hetzner-media/media/shows:/tv + networks: + - media-network + - traefik-public + labels: + - "traefik.enable=true" + - "traefik.http.routers.bazarr.rule=Host(`subtitles.jefflix.lol`)" + - "traefik.http.routers.bazarr.entrypoints=web" + - "traefik.http.services.bazarr.loadbalancer.server.port=6767" + - "traefik.docker.network=traefik-public" + lidarr: image: linuxserver/lidarr:latest container_name: lidarr diff --git a/docker-compose.yml b/docker-compose.yml index fa88cc4..d693099 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -136,6 +136,29 @@ services: - "traefik.http.services.prowlarr.loadbalancer.server.port=9696" - "traefik.docker.network=traefik-public" + # Subtitle Management - Auto-downloads subtitles for all Radarr/Sonarr content + bazarr: + image: linuxserver/bazarr:latest + container_name: bazarr + restart: unless-stopped + environment: + - PUID=1000 + - PGID=1000 + - TZ=Europe/Berlin + volumes: + - ./config/bazarr:/config + - /mnt/hetzner-media/media/movies:/movies + - /mnt/hetzner-media/media/shows:/tv + networks: + - media-network + - traefik-public + labels: + - "traefik.enable=true" + - "traefik.http.routers.bazarr.rule=Host(`subtitles.jefflix.lol`)" + - "traefik.http.routers.bazarr.entrypoints=web" + - "traefik.http.services.bazarr.loadbalancer.server.port=6767" + - "traefik.docker.network=traefik-public" + lidarr: image: linuxserver/lidarr:latest container_name: lidarr diff --git a/scripts/configure-quality-profiles.sh b/scripts/configure-quality-profiles.sh new file mode 100755 index 0000000..b16bf3f --- /dev/null +++ b/scripts/configure-quality-profiles.sh @@ -0,0 +1,142 @@ +#!/bin/bash +# Configure Radarr and Sonarr quality profiles for 1080p minimum +# Run on Netcup after services are running +# +# Usage: ./configure-quality-profiles.sh +# Requires: RADARR_API_KEY and SONARR_API_KEY in .env or environment + +set -euo pipefail + +# Load .env if available +[ -f /opt/media-server/.env ] && { set -a; source /opt/media-server/.env; set +a; } + +RADARR_URL="${RADARR_URL:-http://localhost:7878}" +SONARR_URL="${SONARR_URL:-http://localhost:8989}" + +# Fetch API keys from config if not in environment +if [ -z "${RADARR_API_KEY:-}" ]; then + RADARR_API_KEY=$(grep -oP '\K[^<]+' /opt/media-server/config/radarr/config.xml 2>/dev/null || true) +fi +if [ -z "${SONARR_API_KEY:-}" ]; then + SONARR_API_KEY=$(grep -oP '\K[^<]+' /opt/media-server/config/sonarr/config.xml 2>/dev/null || true) +fi + +if [ -z "$RADARR_API_KEY" ] || [ -z "$SONARR_API_KEY" ]; then + echo "ERROR: Could not find API keys. Set RADARR_API_KEY and SONARR_API_KEY." + exit 1 +fi + +echo "=== Configuring Radarr Quality Profiles ===" + +# Get existing quality profiles from Radarr +RADARR_PROFILES=$(curl -sf "${RADARR_URL}/api/v3/qualityprofile" \ + -H "X-Api-Key: ${RADARR_API_KEY}") + +if [ -z "$RADARR_PROFILES" ]; then + echo "ERROR: Could not fetch Radarr profiles" + exit 1 +fi + +# Update each profile: set cutoff and minimum to 1080p qualities +echo "$RADARR_PROFILES" | jq -c '.[]' | while read -r profile; do + PROFILE_ID=$(echo "$profile" | jq '.id') + PROFILE_NAME=$(echo "$profile" | jq -r '.name') + + # Build updated profile: disable all qualities below 1080p + UPDATED=$(echo "$profile" | jq ' + .items |= map( + if .quality then + # Disable individual qualities below 1080p + if (.quality.resolution // 0) < 1080 and (.quality.resolution // 0) > 0 then + .allowed = false + else + . + end + elif .name then + # Handle quality groups - disable groups that are sub-1080p + if (.name | test("480p|720p|SD|DVD"; "i")) then + .allowed = false | + .items |= map(.allowed = false) + else + . + end + else + . + end + ) | + # Set cutoff to Bluray-1080p quality ID (7) or HDTV-1080p (9) + # This ensures upgrades continue until at least 1080p Bluray + .upgradeAllowed = true + ') + + echo " Updating Radarr profile: ${PROFILE_NAME} (ID: ${PROFILE_ID})" + curl -sf -X PUT "${RADARR_URL}/api/v3/qualityprofile/${PROFILE_ID}" \ + -H "X-Api-Key: ${RADARR_API_KEY}" \ + -H "Content-Type: application/json" \ + -d "$UPDATED" > /dev/null + + echo " Done" +done + +echo "" +echo "=== Configuring Sonarr Quality Profiles ===" + +# Get existing quality profiles from Sonarr +SONARR_PROFILES=$(curl -sf "${SONARR_URL}/api/v3/qualityprofile" \ + -H "X-Api-Key: ${SONARR_API_KEY}") + +if [ -z "$SONARR_PROFILES" ]; then + echo "ERROR: Could not fetch Sonarr profiles" + exit 1 +fi + +echo "$SONARR_PROFILES" | jq -c '.[]' | while read -r profile; do + PROFILE_ID=$(echo "$profile" | jq '.id') + PROFILE_NAME=$(echo "$profile" | jq -r '.name') + + UPDATED=$(echo "$profile" | jq ' + .items |= map( + if .quality then + if (.quality.resolution // 0) < 1080 and (.quality.resolution // 0) > 0 then + .allowed = false + else + . + end + elif .name then + if (.name | test("480p|720p|SD|DVD|SDTV"; "i")) then + .allowed = false | + .items |= map(.allowed = false) + else + . + end + else + . + end + ) | + .upgradeAllowed = true + ') + + echo " Updating Sonarr profile: ${PROFILE_NAME} (ID: ${PROFILE_ID})" + curl -sf -X PUT "${SONARR_URL}/api/v3/qualityprofile/${PROFILE_ID}" \ + -H "X-Api-Key: ${SONARR_API_KEY}" \ + -H "Content-Type: application/json" \ + -d "$UPDATED" > /dev/null + + echo " Done" +done + +echo "" +echo "=== Configuration Complete ===" +echo "" +echo "Quality profiles updated:" +echo " - All sub-1080p qualities (480p, 720p, SD, DVD) DISABLED" +echo " - Upgrades enabled to prefer higher quality" +echo "" +echo "Next steps:" +echo " 1. Start Bazarr: docker compose up -d bazarr" +echo " 2. Open https://subtitles.jefflix.lol" +echo " 3. Connect Bazarr to Radarr (${RADARR_URL}, key from config.xml)" +echo " 4. Connect Bazarr to Sonarr (${SONARR_URL}, key from config.xml)" +echo " 5. Add subtitle providers: OpenSubtitles.com, Subscene, Addic7ed" +echo " 6. Set languages: English (+ any others you want)" +echo " 7. Enable 'Auto' mode so Bazarr fetches subs for all existing + new content" diff --git a/sort-uploads.sh b/sort-uploads.sh new file mode 100644 index 0000000..c79d1d3 --- /dev/null +++ b/sort-uploads.sh @@ -0,0 +1,195 @@ +#!/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