This repository has no description
1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7{
8 # sabnzbd hard-depends on cheetah3, whose PyPI dist is `ct3` while nixpkgs
9 # sets pname="cheetah3". pythonMetadataCheckPhase then looks up metadata for
10 # "cheetah3", finds only "ct3", and fails the build with PackageNotFoundError.
11 # Skip that spurious check until nixpkgs fixes the pname/dist mismatch.
12 nixpkgs.overlays = [
13 (_final: prev: {
14 pythonPackagesExtensions = prev.pythonPackagesExtensions ++ [
15 (_pyfinal: pyprev: {
16 cheetah3 = pyprev.cheetah3.overridePythonAttrs (_: {
17 dontCheckPythonMetadata = true;
18 });
19 })
20 ];
21 })
22 ];
23 users.users.download = {
24 isSystemUser = true;
25 uid = 1001;
26 group = "download";
27 };
28 users.groups.download.members = [
29 "transmission"
30 "sabnzbd"
31 ];
32 services.transmission = {
33 enable = true;
34 package = pkgs.transmission_4;
35 home = "/var/state/transmission";
36 group = "download";
37 downloadDirPermissions = "775";
38 settings = {
39 download-dir = "/var/download/transmission";
40 incomplete-dir-enabled = false;
41 rpc-bind-address = "::";
42 message-level = 1;
43 # 0o002: files 664 so sonarr/radarr (group download) can hardlink
44 # instead of copying (fs.protected_hardlinks needs owner or write)
45 umask = 2;
46 rpc-whitelist-enabled = false;
47 rpc-host-whitelist-enabled = false;
48 # stop seeding after reaching 5.0 ratio
49 ratio-limit = 5;
50 ratio-limit-enabled = true;
51 # limit concurrent seeding to reduce resource usage
52 seed-queue-enabled = true;
53 seed-queue-size = 20;
54 };
55 };
56
57 # garbage collection for old torrents
58 systemd.services.transmission-gc = {
59 description = "Remove old completed torrents from Transmission";
60 after = [ "transmission.service" ];
61 path = [
62 pkgs.transmission_4
63 pkgs.jq
64 pkgs.coreutils
65 pkgs.gawk
66 pkgs.gnugrep
67 pkgs.findutils
68 ];
69 script = ''
70 set -efu
71 # transmission lives in its own netns (t1 = 128.0.0.1/30), this unit runs
72 # outside it (t2 = 128.0.0.2), so localhost is *not* the daemon here.
73 TRANSMISSION_HOST="128.0.0.1:9091"
74 MAX_AGE_DAYS=30
75 MIN_RATIO=5.0
76
77 # get list of all torrents as JSON
78 torrents=$(transmission-remote "$TRANSMISSION_HOST" -l 2>/dev/null | tail -n +2 | head -n -1 || true)
79
80 if [ -z "$torrents" ]; then
81 echo "No torrents found"
82 exit 0
83 fi
84
85 # process each torrent
86 echo "$torrents" | while read -r line; do
87 id=$(echo "$line" | awk '{print $1}' | tr -d '*')
88 # skip if not a valid ID
89 [ -z "$id" ] || [ "$id" = "ID" ] && continue
90
91 # get torrent info
92 info=$(transmission-remote "$TRANSMISSION_HOST" -t "$id" -i 2>/dev/null || continue)
93
94 # extract ratio and completion status
95 ratio=$(echo "$info" | grep "Ratio:" | awk '{print $2}')
96 percent=$(echo "$info" | grep "Percent Done:" | awk '{print $3}' | tr -d '%')
97 state=$(echo "$info" | grep "State:" | cut -d: -f2- | xargs)
98
99 # skip if not 100% complete
100 [ "$percent" != "100" ] && continue
101
102 # check if ratio reached
103 ratio_reached=false
104 if [ -n "$ratio" ] && [ "$ratio" != "None" ]; then
105 if awk "BEGIN {exit !($ratio >= $MIN_RATIO)}"; then
106 ratio_reached=true
107 fi
108 fi
109
110 # check age via date added
111 date_added=$(echo "$info" | grep "Date added:" | cut -d: -f2- | xargs)
112 if [ -n "$date_added" ]; then
113 added_epoch=$(date -d "$date_added" +%s 2>/dev/null || echo 0)
114 now_epoch=$(date +%s)
115 age_days=$(( (now_epoch - added_epoch) / 86400 ))
116 else
117 age_days=0
118 fi
119
120 # remove if ratio reached OR older than max age
121 if [ "$ratio_reached" = "true" ]; then
122 echo "Removing torrent $id (ratio: $ratio >= $MIN_RATIO)"
123 transmission-remote "$TRANSMISSION_HOST" -t "$id" --remove-and-delete
124 elif [ "$age_days" -ge "$MAX_AGE_DAYS" ]; then
125 echo "Removing torrent $id (age: $age_days days >= $MAX_AGE_DAYS)"
126 transmission-remote "$TRANSMISSION_HOST" -t "$id" --remove-and-delete
127 fi
128 done
129 '';
130 serviceConfig = {
131 Type = "oneshot";
132 User = "transmission";
133 };
134 };
135
136 systemd.timers.transmission-gc = {
137 wantedBy = [ "timers.target" ];
138 timerConfig = {
139 OnCalendar = "daily";
140 Persistent = true;
141 RandomizedDelaySec = "1h";
142 };
143 };
144 systemd.services.transmission-watcher = {
145 wantedBy = [ "multi-user.target" ];
146 startAt = "*:0/5";
147 path = [
148 pkgs.curl
149 pkgs.systemdMinimal
150 ];
151 script = ''
152 set -efu -o pipefail
153 # don't restart if transmission is currently starting or stopping
154 state=$(systemctl show -p ActiveState --value transmission.service)
155 if [ "$state" = "activating" ] || [ "$state" = "deactivating" ]; then
156 echo "transmission is $state, skipping"
157 exit 0
158 fi
159 # check if transmission responds (with timeout)
160 if ! curl -SsfL --max-time 10 http://transmission.r; then
161 echo "transmission not responding, restarting"
162 systemctl restart transmission.service
163 fi
164 '';
165 };
166
167 # pause downloads when /var/download runs low, so a full disk can no longer
168 # hard-wedge sabnzbd with an unrecoverable "Disk full! Forcing Pause".
169 # sabnzbd pauses itself once free disk drops below download_free/complete_free
170 # and only auto-resumes above if fulldisk_autoresume is set (default off);
171 # this guard keeps those at THRESH GiB / on, and additionally sends a resume
172 # on recovery. Transmission has no equivalent, so below the threshold the
173 # guard stops all its torrents (restarting them on recovery) and posts a
174 # Matrix alert on each transition.
175 systemd.services.download-space-guard = {
176 wantedBy = [ "multi-user.target" ];
177 after = [ "sabnzbd.service" ];
178 startAt = "*:0/5";
179 path = [
180 pkgs.curl
181 pkgs.coreutils
182 pkgs.gnugrep
183 pkgs.gawk
184 pkgs.jq
185 pkgs.transmission_4
186 ];
187 script = ''
188 set -efu
189 DIR=/var/download
190 THRESH=100
191 # !MRjkvAaFPsjdYCpeiZ:lassul.us, URL-encoded for the API path
192 ROOM=%21MRjkvAaFPsjdYCpeiZ%3Alassul.us
193 STATE="$STATE_DIRECTORY/state"
194
195 avail=$(df -Pk "$DIR" | awk 'NR==2 {print int($4 / 1024 / 1024)}')
196
197 INI=/var/lib/sabnzbd/sabnzbd.ini
198 key=$(grep -E '^api_key' "$INI" | head -1 | cut -d= -f2 | tr -d ' ')
199 sab() {
200 curl -fsS --max-time 15 "http://127.0.0.1:8080/api?output=json&apikey=$key&$1" >/dev/null || :
201 }
202 tr_remote() {
203 transmission-remote 128.0.0.1:9091 "$@" >/dev/null || :
204 }
205 notify() {
206 token=$(cat "$CREDENTIALS_DIRECTORY/matrix-token")
207 body=$(jq -nc --arg b "$1" '{msgtype: "m.text", body: $b}')
208 curl -fsS --max-time 15 -X POST \
209 -H "Authorization: Bearer $token" -H 'Content-Type: application/json' \
210 "https://matrix.lassul.us/_matrix/client/v3/rooms/$ROOM/send/m.room.message" \
211 --data "$body" >/dev/null || :
212 }
213
214 # sabnzbd pauses continuously once free disk drops below these; keep
215 # them at THRESH GiB and auto-resume on; re-assert only if changed.
216 for k in download_free complete_free; do
217 grep -qxF "$k = ''${THRESH}G" "$INI" \
218 || sab "mode=set_config§ion=misc&keyword=$k&value=''${THRESH}G"
219 done
220 grep -qxF "fulldisk_autoresume = 1" "$INI" \
221 || sab "mode=set_config§ion=misc&keyword=fulldisk_autoresume&value=1"
222
223 prev=ok
224 if [ -f "$STATE" ]; then prev=$(cat "$STATE"); fi
225 if [ "$avail" -lt "$THRESH" ]; then cur=low; else cur=ok; fi
226
227 if [ "$cur" = low ]; then
228 # stop everything each run so newly-grabbed torrents can't fill the disk
229 tr_remote -t all --stop
230 if [ "$prev" != low ]; then
231 echo "free ''${avail}G < ''${THRESH}G: stopped transmission, alerting"
232 notify "⚠️ yellow: /var/download down to ''${avail} GiB free (< ''${THRESH} GiB). Stopped all transmission torrents; sabnzbd auto-paused via download_free."
233 fi
234 elif [ "$prev" = low ]; then
235 echo "free ''${avail}G >= ''${THRESH}G: resumed transmission and sabnzbd, alerting"
236 tr_remote -t all --start
237 sab "mode=resume"
238 notify "✅ yellow: /var/download recovered to ''${avail} GiB free. Resumed sabnzbd and transmission torrents."
239 fi
240 echo "$cur" > "$STATE"
241 '';
242 serviceConfig = {
243 Type = "oneshot";
244 User = "root";
245 StateDirectory = "download-space-guard";
246 LoadCredential = [
247 "matrix-token:${config.clan.core.vars.generators.archiver-matrix.files."matrix-access-token".path}"
248 ];
249 };
250 };
251
252 security.acme.defaults.email = "spam@krebsco.de";
253 security.acme.acceptTerms = true;
254 services.nginx = {
255 enable = true;
256 package = pkgs.nginx.override {
257 modules = with pkgs.nginxModules; [
258 fancyindex
259 ];
260 };
261 virtualHosts."yellow.r" = {
262 serverAliases = [ "flix.r" ];
263 default = true;
264 locations."/" = {
265 root = "/var/download";
266 extraConfig = ''
267 fancyindex on;
268 include ${pkgs.nginx}/conf/mime.types;
269 include ${pkgs.writeText "extrMime" ''
270 types {
271 video/webm mkv;
272 }
273 ''};
274 create_full_put_path on;
275 '';
276 };
277
278 };
279 virtualHosts."jelly.r" = {
280 locations."/".extraConfig = ''
281 proxy_pass http://localhost:8096/;
282 proxy_set_header Accept-Encoding "";
283 '';
284 };
285 virtualHosts."transmission.r" = {
286 locations."/" = {
287 proxyWebsockets = true;
288 proxyPass = "http://128.0.0.1:9091";
289 };
290 };
291 virtualHosts."radar.r" = {
292 locations."/" = {
293 proxyWebsockets = true;
294 proxyPass = "http://localhost:7878";
295 };
296 };
297 virtualHosts."sonar.r" = {
298 locations."/" = {
299 proxyWebsockets = true;
300 proxyPass = "http://localhost:8989";
301 # sonar.r now has forced authentication
302 extraConfig = ''
303 proxy_set_header Authorization "Basic a3JlYnM6YWlkc2JhbGxz";
304 '';
305 };
306 };
307 };
308
309 services.samba = {
310 enable = true;
311 enableNmbd = false;
312 settings.global = {
313 "hosts allow" = "42::/16 10.243.0.0/16 10.244.0.0/16 fdcc:c5da:5295:c853:d499::/80";
314 "use sendfile" = "true";
315 "disable netbios" = "true";
316 "mangled names" = "illegal";
317 "socket options" = "TCP_NODELAY IPTOS_LOWDELAY SO_RCVBUF=65536 SO_SNDBUF=65536";
318 "load printers" = "false";
319 "disable spoolss" = "true";
320 "printcap name" = "/dev/null";
321 "map to guest" = "Bad User";
322 "max log size" = "50";
323 "dns proxy" = "no";
324 "security" = "user";
325 "syslog only" = "yes";
326 };
327 shares.public = {
328 comment = "Warez";
329 path = "/var/download";
330 public = "yes";
331 "only guest" = "yes";
332 "create mask" = "0644";
333 "directory mask" = "2777";
334 writable = "no";
335 printable = "no";
336 };
337 };
338
339 networking.firewall.allowedTCPPorts = [
340 80 # nginx
341 443 # nginx
342 9091 # transmission web
343 8096 # jellyfin
344 8920 # jellyfin
345 51413 # transmission traffic
346 445 # smbd
347 111 # smbd
348 2049 # smbd
349 4000 # smbd
350 4001 # smbd
351 4002 # smbd
352 ];
353 networking.firewall.allowedUDPPorts = [
354 51413 # transmission traffic
355 1900 # jellyfin
356 7359 # jellyfin
357 111 # smbd
358 2049 # smbd
359 4000 # smbd
360 4001 # smbd
361 4002 # smbd
362 ];
363 # we need this so we can forward into the the transmission network namespace
364 networking.firewall.filterForward = false;
365 # transmission rpc port
366 networking.nftables.tables.flix = {
367 family = "ip6";
368 content = ''
369 chain prerouting {
370 type nat hook prerouting priority dstnat; policy accept;
371 iifname "retiolum" tcp dport 9091 dnat to fdb4:3310:947::2
372 }
373 '';
374 };
375
376 systemd.services.flix-index = {
377 wantedBy = [ "multi-user.target" ];
378 path = [
379 pkgs.coreutils
380 pkgs.findutils
381 pkgs.inotify-tools
382 ];
383 startAt = "hourly";
384 serviceConfig = {
385 Type = "oneshot";
386 ExecStart = pkgs.writers.writeDash "flix-index" ''
387 set -efu
388 index(){
389 # /var/download churns constantly (download temp files appearing and
390 # vanishing), so find can exit non-zero when an entry disappears
391 # mid-scan. Ignore that — the entries we did enumerate are still valid
392 # — but only replace the live index if we actually produced one.
393 find . -type f > "$DIR"/index.tmp || :
394 if [ -s "$DIR"/index.tmp ]; then
395 mv "$DIR"/index.tmp "$DIR"/index
396 fi
397 }
398
399 DIR=/var/download
400 cd "$DIR"
401 index
402 '';
403 };
404 };
405
406 services.jellyfin = {
407 enable = true;
408 group = "download";
409 };
410
411 # request managment
412 services.jellyseerr = {
413 enable = true;
414 openFirewall = true;
415 };
416
417 # movies
418 services.radarr = {
419 enable = true;
420 openFirewall = true;
421 user = "download";
422 group = "download";
423 };
424
425 # shows
426 services.sonarr = {
427 enable = true;
428 openFirewall = true;
429 user = "download";
430 group = "download";
431 };
432 # sonarr needs unsecure packages
433 nixpkgs.config.permittedInsecurePackages = [
434 "aspnetcore-runtime-wrapped-6.0.36"
435 "aspnetcore-runtime-6.0.36"
436 "dotnet-sdk-wrapped-6.0.428"
437 "dotnet-sdk-6.0.428"
438 "olm-3.2.16"
439 ];
440
441 # indexers
442 services.prowlarr = {
443 enable = true;
444 openFirewall = true;
445 };
446
447 # flaresolverr for bypassing cloudflare on some indexers
448 services.flaresolverr = {
449 enable = true;
450 openFirewall = true;
451 };
452
453 # subtitles
454 services.bazarr = {
455 enable = true;
456 openFirewall = true;
457 user = "download";
458 group = "download";
459 };
460
461 # usenet download client
462 services.sabnzbd = {
463 enable = true;
464 group = "download";
465 openFirewall = true;
466 };
467 # sonarr/radarr copy on import and leave the originals in sabnzbd's
468 # complete_dir (/var/download/usenet) forever; purge anything untouched
469 # for a week. Active imports are always younger than that.
470 systemd.tmpfiles.rules = [
471 "e /var/download/usenet - - - 7d"
472 ];
473
474 # Configs without media, downloads, logs and image caches; the SQLite
475 # databases are dumped consistently instead of copied.
476 lass.backup.state.flix =
477 let
478 dbs = {
479 jellyfin = "/var/lib/jellyfin/data/*.db";
480 sonarr = "${config.services.sonarr.dataDir}/sonarr.db";
481 radarr = "${config.services.radarr.dataDir}/radarr.db";
482 prowlarr = "/var/lib/private/prowlarr/prowlarr.db";
483 jellyseerr = "/var/lib/private/jellyseerr/config/db/db.sqlite3";
484 sabnzbd = "/var/lib/sabnzbd/admin/history1.db";
485 };
486 dirs = [
487 "/var/lib/jellyfin"
488 config.services.sonarr.dataDir
489 config.services.radarr.dataDir
490 "/var/lib/private/prowlarr"
491 "/var/lib/private/jellyseerr"
492 "/var/lib/sabnzbd"
493 ];
494 in
495 {
496 folders = dirs;
497 exclude = [
498 "/var/lib/jellyfin/metadata"
499 "/var/lib/jellyfin/log"
500 "/var/lib/jellyfin/transcodes"
501 "${config.services.sonarr.dataDir}/MediaCover"
502 "${config.services.sonarr.dataDir}/logs"
503 "${config.services.radarr.dataDir}/MediaCover"
504 "${config.services.radarr.dataDir}/logs"
505 "/var/lib/private/prowlarr/logs"
506 "/var/lib/private/jellyseerr/config/cache"
507 "/var/lib/private/jellyseerr/config/logs"
508 "/var/lib/private/jellyseerr/config/db/db.sqlite3*"
509 "/var/lib/sabnzbd/Downloads"
510 "/var/lib/sabnzbd/logs"
511 ]
512 # the live databases, also the unneeded servarr logs.db
513 ++ map (dir: "${dir}/*.db") dirs
514 ++ map (dir: "${dir}/*.db-*") dirs;
515 preBackup = ''
516 rm -rf /var/backup/flix
517 ${lib.concatStrings (
518 lib.mapAttrsToList (name: glob: ''
519 mkdir -p /var/backup/flix/${name}
520 for db in ${glob}; do
521 # sqlite3 would create a missing database
522 test -f "$db"
523 ${pkgs.sqlite}/bin/sqlite3 -cmd ".timeout 60000" "$db" ".backup '/var/backup/flix/${name}/$(basename "$db")'"
524 done
525 '') dbs
526 )}
527 '';
528 # WAL-mode SQLite readers need to write the -shm file
529 readWritePaths = map dirOf (lib.attrValues dbs);
530 };
531}