feat(media): add Bazarr subtitle manager and 1080p quality enforcement

- Add Bazarr service to both docker-compose files (port 6767, subtitles.jefflix.lol)
- Add sort-uploads.sh with 1080p minimum enforcement and quality scoring
- Add scripts/configure-quality-profiles.sh to update Radarr/Sonarr quality profiles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-08 10:12:12 -04:00
parent 4a2ccc65c7
commit 5026e07d76
4 changed files with 383 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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 '<ApiKey>\K[^<]+' /opt/media-server/config/radarr/config.xml 2>/dev/null || true)
fi
if [ -z "${SONARR_API_KEY:-}" ]; then
SONARR_API_KEY=$(grep -oP '<ApiKey>\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"

195
sort-uploads.sh Normal file
View File

@ -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