This repository has no description
0

Configure Feed

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

tools.flix-prune: report + prune sonarr series by size and jellyfin watch state

also document why transmission-gc talks to 128.0.0.1: the daemon runs in its
own netns (t1), the gc unit does not (t2), so localhost is not the daemon.

+266
+21
tools/flix-prune/flake-module.nix
··· 1 + { ... }: 2 + { 3 + perSystem = 4 + { pkgs, ... }: 5 + { 6 + packages.flix-prune = 7 + (pkgs.writeShellApplication { 8 + name = "flix-prune"; 9 + runtimeInputs = [ 10 + pkgs.coreutils 11 + pkgs.curl 12 + pkgs.gawk 13 + pkgs.gnused 14 + pkgs.jq 15 + pkgs.sqlite 16 + ]; 17 + text = builtins.readFile ./flix-prune.sh; 18 + }).overrideAttrs 19 + { passthru.usage = builtins.readFile ./usage.kdl; }; 20 + }; 21 + }
+216
tools/flix-prune/flix-prune.sh
··· 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. 6 + set -euo pipefail 7 + 8 + SONARR_URL="${SONARR_URL:-http://127.0.0.1:8989}" 9 + JELLYFIN_DB="${JELLYFIN_DB:-/var/lib/jellyfin/data/jellyfin.db}" 10 + SONARR_CONFIG="${SONARR_CONFIG:-/var/lib/sonarr/.config/NzbDrone/config.xml}" 11 + 12 + min_size_gib=0 13 + untouched_only=false 14 + as_json=false 15 + assume_yes=false 16 + add_exclusion=true 17 + cmd="" 18 + targets=() 19 + 20 + usage() { 21 + cat >&2 <<'EOF' 22 + usage: flix-prune list [--untouched] [--min-size GiB] [--json] 23 + flix-prune delete <series|id>... [--yes] [--keep-exclusion] 24 + flix-prune unmonitor <series|id>... 25 + 26 + env: SONARR_URL, SONARR_API_KEY, SONARR_CONFIG, JELLYFIN_DB 27 + EOF 28 + exit 1 29 + } 30 + 31 + while [ $# -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 58 + done 59 + [ -n "$cmd" ] || usage 60 + 61 + if [ -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 68 + fi 69 + 70 + sonarr() { 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. 80 + watch_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 "" 86 + select 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) 92 + from BaseItems s 93 + join BaseItems e 94 + on e.SeriesId = s.Id 95 + and e.Type = 'MediaBrowser.Controller.Entities.TV.Episode' 96 + left join UserData ud on ud.ItemId = e.Id 97 + where s.Type = 'MediaBrowser.Controller.Entities.TV.Series' 98 + and s.Path is not null 99 + group by s.Path; 100 + EOF 101 + } 102 + 103 + # series objects enriched with watch data, biggest first 104 + series_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 + 128 + human() { 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 + 150 + resolve() { 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 + 168 + case "$cmd" in 169 + list) 170 + if $as_json; then series_json; else series_json | human; fi 171 + ;; 172 + delete | 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 + ;; 216 + esac
+27
tools/flix-prune/usage.kdl
··· 1 + bin "flix-prune" 2 + about "Report disk usage per Sonarr series (joined with Jellyfin watch data) and delete/unmonitor them without leaving Sonarr re-downloading" 3 + 4 + cmd "list" help="Show series by size on disk, with Jellyfin watch info" { 5 + flag "--untouched" help="Only series nobody ever started watching" 6 + flag "--min-size" help="Skip series smaller than N GiB" { 7 + arg "<gib>" 8 + } 9 + flag "--json" help="Emit raw JSON instead of a table" 10 + } 11 + 12 + cmd "delete" help="Delete files, remove the series from Sonarr and exclude it from import lists" { 13 + arg "<series>..." help="Series title (exact, case-insensitive) or Sonarr id" 14 + flag "--yes" help="Actually delete; without it only a dry-run report is printed" 15 + flag "--keep-exclusion" help="Do not add an import list exclusion" 16 + } 17 + 18 + cmd "unmonitor" help="Stop Sonarr from grabbing more episodes but keep existing files" { 19 + arg "<series>..." help="Series title (exact, case-insensitive) or Sonarr id" 20 + } 21 + 22 + flag "--sonarr-url" help="Sonarr base URL (default http://127.0.0.1:8989, env SONARR_URL)" { 23 + arg "<url>" 24 + } 25 + flag "--jellyfin-db" help="Jellyfin library db (default /var/lib/jellyfin/data/jellyfin.db, env JELLYFIN_DB)" { 26 + arg "<path>" 27 + }
+2
2configs/services/flix/default.nix
··· 63 63 ]; 64 64 script = '' 65 65 set -efu 66 + # transmission lives in its own netns (t1 = 128.0.0.1/30), this unit runs 67 + # outside it (t2 = 128.0.0.2), so localhost is *not* the daemon here. 66 68 TRANSMISSION_HOST="128.0.0.1:9091" 67 69 MAX_AGE_DAYS=30 68 70 MIN_RATIO=5.0