Docker & Media Library

A menu script for Docker media containers, with a VPN routing check

If you run a group of media containers (the *arr apps, download clients, flaresolverr and so on), a small menu script saves typing for the common jobs. The most useful part is a check that download clients really leave through the VPN.

Pick the "media" group by name

media_containers() {
  docker ps --format '{{.Names}}' |
    grep -E 'sonarr|radarr|lidarr|prowlarr|bazarr|sabnzbd|qbittorrent|deluge|transmission|flaresolverr' | sort
}

A simple menu

PS3="Choose: "
select opt in "List containers" "Restart media group" "Check VPN routing" "Quit"; do
  case $REPLY in
    1) docker ps --format 'table {{.Names}}\t{{.Status}}' ;;
    2) read -rp "Restart $(media_containers | wc -l) containers? (y/N) " a
       [[ $a == [yY] ]] && media_containers | xargs -r docker restart ;;
    3) vpn_check ;;
    4) break ;;
  esac
done

Ask for confirmation before bulk actions. A "restart all" that also starts stopped containers will bring back anything you stopped on purpose.

Check that download clients use the VPN

Compare the public IP seen from inside each client with the host's own public IP. They should be different.

vpn_check() {
  local host_ip c ip
  host_ip=$(curl -fsS https://ifconfig.me)
  for c in qbittorrent deluge transmission sabnzbd; do
    docker inspect "$c" >/dev/null 2>&1 || continue
    ip=$(docker exec "$c" curl -fsS --max-time 10 https://ifconfig.me 2>/dev/null) || ip="(no answer)"
    if [[ $ip == "$host_ip" ]]; then echo "LEAK  $c: $ip (same as host)"
    else echo "OK    $c: $ip"; fi
  done
}

Small bash pitfalls in scripts like this

Clean up leftover {tmdb-...} folders in a media library

Media managers (Radarr, Sonarr, Jellyfin) name folders like Movie (2020) {tmdb-12345}. When an item is removed or re-imported, a folder can be left behind with just artwork or an .nfo file. This how-to finds those folders and removes them, after showing you the list first.

Find candidates (dry run)

A candidate is a folder with { or } in its name, no subfolders, exactly two files, and no video file:

#!/bin/bash
root=${1:?usage: tmdb-clean <library> [--delete]}
find "$root" -mindepth 1 -type d \( -name '*{*' -o -name '*}*' \) -print0 |
while IFS= read -r -d '' d; do
  subs=$(find "$d" -mindepth 1 -maxdepth 1 -type d | wc -l)
  files=$(find "$d" -maxdepth 1 -type f | wc -l)
  vids=$(find "$d" -maxdepth 1 -type f \( -iname '*.mkv' -o -iname '*.mp4' -o -iname '*.avi' -o -iname '*.m4v' \) | wc -l)
  if (( subs == 0 && files == 2 && vids == 0 )); then
    if [[ ${2:-} == --delete ]]; then rm -rf -- "$d"; echo "deleted: $d"
    else echo "would delete: $d"; fi
  fi
done

Use it

./tmdb-clean.sh /mnt/media/movies            # review the list
./tmdb-clean.sh /mnt/media/movies --delete   # then delete

Remove movie folders that have no video files (safely)

Over time a movie library collects folders that hold only posters, .nfo files or subtitles, because the movie itself was deleted or moved. This finds them, shows the list, and deletes only after you confirm.

The logic

A folder is a candidate when nothing under it is a video file. Never include the library root itself (-mindepth 1).

#!/bin/bash
set -euo pipefail
root=${1:?usage: clean-empty-movies <library> [--delete]}
has_video() {
  find "$1" -type f \( -iname '*.mkv' -o -iname '*.mp4' -o -iname '*.m4v' -o -iname '*.avi' -o -iname '*.wmv' \) \
       -print -quit | grep -q .
}
mapfile -d '' dirs < <(find "$root" -mindepth 1 -depth -type d -print0)
todo=()
for d in "${dirs[@]}"; do
  has_video "$d" || todo+=("$d")
done
printf '%s\n' "${todo[@]}"
[[ ${2:-} == --delete ]] || { echo "Dry run. Re-run with --delete."; exit 0; }
read -rp "Delete ${#todo[@]} folders? (y/N) " a
[[ $a == [yY] ]] || exit 0
for d in "${todo[@]}"; do rm -rf -- "$d"; done

Key points