This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

superconfig / tools / flix-prune / flix-prune.sh
6.7 kB 216 lines
1#!/usr/bin/env bash 2# Report/prune the Sonarr library on yellow. Deleting media by hand is a trap: 3# Sonarr keeps the series monitored and just grabs everything again on the next 4# RSS sync, so the disk fills up a second time. Everything here goes through the 5# Sonarr API so the file removal and the unmonitoring stay in sync. 6set -euo pipefail 7 8SONARR_URL="${SONARR_URL:-http://127.0.0.1:8989}" 9JELLYFIN_DB="${JELLYFIN_DB:-/var/lib/jellyfin/data/jellyfin.db}" 10SONARR_CONFIG="${SONARR_CONFIG:-/var/lib/sonarr/.config/NzbDrone/config.xml}" 11 12min_size_gib=0 13untouched_only=false 14as_json=false 15assume_yes=false 16add_exclusion=true 17cmd="" 18targets=() 19 20usage() { 21 cat >&2 <<'EOF' 22usage: flix-prune list [--untouched] [--min-size GiB] [--json] 23 flix-prune delete <series|id>... [--yes] [--keep-exclusion] 24 flix-prune unmonitor <series|id>... 25 26env: SONARR_URL, SONARR_API_KEY, SONARR_CONFIG, JELLYFIN_DB 27EOF 28 exit 1 29} 30 31while [ $# -gt 0 ]; do 32 case "$1" in 33 list | delete | unmonitor) 34 [ -z "$cmd" ] || usage 35 cmd="$1" 36 ;; 37 --untouched) untouched_only=true ;; 38 --json) as_json=true ;; 39 --yes) assume_yes=true ;; 40 --keep-exclusion) add_exclusion=false ;; 41 --min-size) 42 shift 43 min_size_gib="${1?--min-size needs an argument}" 44 ;; 45 --sonarr-url) 46 shift 47 SONARR_URL="${1?--sonarr-url needs an argument}" 48 ;; 49 --jellyfin-db) 50 shift 51 JELLYFIN_DB="${1?--jellyfin-db needs an argument}" 52 ;; 53 -h | --help) usage ;; 54 -*) usage ;; 55 *) targets+=("$1") ;; 56 esac 57 shift 58done 59[ -n "$cmd" ] || usage 60 61if [ -z "${SONARR_API_KEY:-}" ]; then 62 if [ -r "$SONARR_CONFIG" ]; then 63 SONARR_API_KEY=$(sed -n 's,.*<ApiKey>\(.*\)</ApiKey>.*,\1,p' "$SONARR_CONFIG") 64 else 65 echo "flix-prune: no SONARR_API_KEY and $SONARR_CONFIG is unreadable" >&2 66 exit 1 67 fi 68fi 69 70sonarr() { 71 local method="$1" path="$2" 72 shift 2 73 curl -sSf -X "$method" -H "X-Api-Key: $SONARR_API_KEY" \ 74 -H 'Content-Type: application/json' "$SONARR_URL/api/v3$path" "$@" 75} 76 77# One row per series path: last play date, total play count and the number of 78# episodes any user ever started. Play state lives per user, and this box has 79# ~65 of them, so aggregate over all of them. 80watch_tsv() { 81 [ -r "$JELLYFIN_DB" ] || return 0 82 sqlite3 "file:$JELLYFIN_DB?immutable=1" <<'EOF' 2>/dev/null || true 83.mode list 84.separator "\t" 85.nullvalue "" 86select s.Path, 87 ifnull(max(ud.LastPlayedDate), ''), 88 ifnull(sum(ud.PlayCount), 0), 89 count(distinct case 90 when ud.Played = 1 or ud.PlayCount > 0 or ud.PlaybackPositionTicks > 0 91 then e.Id end) 92from BaseItems s 93join BaseItems e 94 on e.SeriesId = s.Id 95 and e.Type = 'MediaBrowser.Controller.Entities.TV.Episode' 96left join UserData ud on ud.ItemId = e.Id 97where s.Type = 'MediaBrowser.Controller.Entities.TV.Series' 98 and s.Path is not null 99group by s.Path; 100EOF 101} 102 103# series objects enriched with watch data, biggest first 104series_json() { 105 local watch 106 watch=$(watch_tsv) 107 sonarr GET /series | jq \ 108 --rawfile watch <(printf '%s' "$watch") \ 109 --argjson min "$min_size_gib" \ 110 --argjson untouched "$untouched_only" ' 111 ($watch | split("\n") | map(select(length > 0) | split("\t")) 112 | map({ key: .[0], value: { last: .[1], plays: (.[2] | tonumber), touched: (.[3] | tonumber) } }) 113 | from_entries) as $w 114 | map((.path // "") as $p | { 115 id, title, path, monitored, status, 116 size: (.statistics.sizeOnDisk // 0), 117 files: (.statistics.episodeFileCount // 0), 118 last: ($w[$p].last // ""), 119 plays: ($w[$p].plays // 0), 120 touched: ($w[$p].touched // 0), 121 known: ($w | has($p)), 122 }) 123 | map(select(.size >= ($min * 1073741824))) 124 | if $untouched then map(select(.touched == 0 and .size > 0)) else . end 125 | sort_by(-.size)' 126} 127 128human() { 129 jq -r '.[] | [ 130 (.size / 1073741824 | . * 10 | round / 10 | tostring), 131 (.files | tostring), 132 (if .size > 0 and .files > 0 then (.size / .files / 1073741824 * 100 | round / 100 | tostring) else "-" end), 133 (if .touched > 0 then (.touched | tostring) else (if .known then "0" else "?" end) end), 134 (.plays | tostring), 135 (if .last == "" then "never" else .last[0:10] end), 136 (if .monitored then "mon" else "-" end), 137 .status, 138 .title 139 ] | @tsv' | 140 awk -F'\t' 'BEGIN { 141 printf "%9s %6s %7s %8s %6s %-10s %-4s %-10s %s\n", "SIZE/GiB", "FILES", "GiB/EP", "TOUCHED", "PLAYS", "LAST", "MON", "STATUS", "TITLE" 142 } { 143 printf "%9s %6s %7s %8s %6s %-10s %-4s %-10s %s\n", $1, $2, $3, $4, $5, $6, $7, $8, $9 144 total += $1 145 } END { 146 printf "%9.1f %6s %7s %8s %6s %-10s %-4s %-10s %s\n", total, "", "", "", "", "", "", "", "(" NR " series)" 147 }' 148} 149 150resolve() { 151 local all="$1" wanted="$2" hit 152 hit=$(jq -c --arg w "$wanted" ' 153 [ .[] | select((.id | tostring) == $w or (.title | ascii_downcase) == ($w | ascii_downcase)) ]' <<<"$all") 154 case "$(jq length <<<"$hit")" in 155 0) 156 echo "flix-prune: no series matching '$wanted'" >&2 157 return 1 158 ;; 159 1) jq -c '.[0]' <<<"$hit" ;; 160 *) 161 echo "flix-prune: '$wanted' is ambiguous:" >&2 162 jq -r '.[] | " \(.id)\t\(.title)"' <<<"$hit" >&2 163 return 1 164 ;; 165 esac 166} 167 168case "$cmd" in 169list) 170 if $as_json; then series_json; else series_json | human; fi 171 ;; 172delete | unmonitor) 173 [ "${#targets[@]}" -gt 0 ] || usage 174 all=$(sonarr GET /series) 175 picked=() 176 for want in "${targets[@]}"; do 177 picked+=("$(resolve "$all" "$want")") 178 done 179 180 freed=0 181 for row in "${picked[@]}"; do 182 size=$(jq -r '.statistics.sizeOnDisk // 0' <<<"$row") 183 freed=$((freed + size)) 184 printf '%s (id %s, %s GiB, %s files, monitored=%s)\n' \ 185 "$(jq -r .title <<<"$row")" "$(jq -r .id <<<"$row")" \ 186 "$((size / 1073741824))" \ 187 "$(jq -r '.statistics.episodeFileCount // 0' <<<"$row")" \ 188 "$(jq -r .monitored <<<"$row")" 189 done 190 if [ "$cmd" = delete ]; then 191 printf -- '---\nwould free %s GiB across %s series\n' "$((freed / 1073741824))" "${#picked[@]}" 192 else 193 printf -- '---\nkeeping %s GiB on disk across %s series\n' "$((freed / 1073741824))" "${#picked[@]}" 194 fi 195 196 if [ "$cmd" = unmonitor ]; then 197 for row in "${picked[@]}"; do 198 id=$(jq -r .id <<<"$row") 199 jq -c '.monitored = false | .addOptions = {}' <<<"$row" | 200 sonarr PUT "/series/$id" --data-binary @- >/dev/null 201 echo "unmonitored $(jq -r .title <<<"$row")" 202 done 203 exit 0 204 fi 205 206 if ! $assume_yes; then 207 echo "dry-run: re-run with --yes to delete files and remove from Sonarr" >&2 208 exit 0 209 fi 210 for row in "${picked[@]}"; do 211 id=$(jq -r .id <<<"$row") 212 sonarr DELETE "/series/$id?deleteFiles=true&addImportListExclusion=$add_exclusion" >/dev/null 213 echo "deleted $(jq -r .title <<<"$row")" 214 done 215 ;; 216esac