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:
parent
4a2ccc65c7
commit
5026e07d76
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue