forked from
lass.pds.lassul.us/superconfig
This repository has no description
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