diff --git a/hosts/iron/services/default.nix b/hosts/iron/services/default.nix index 331bfd0..6c0e206 100644 --- a/hosts/iron/services/default.nix +++ b/hosts/iron/services/default.nix @@ -6,7 +6,7 @@ ./dyndns.nix ./esphome ./home-assistant.nix - ./jellyfin.nix + ./jellyfin ./mail.nix ./matrix.nix ./navidrome.nix diff --git a/hosts/iron/services/jellyfin.nix b/hosts/iron/services/jellyfin/default.nix similarity index 98% rename from hosts/iron/services/jellyfin.nix rename to hosts/iron/services/jellyfin/default.nix index 0842575..1da1106 100644 --- a/hosts/iron/services/jellyfin.nix +++ b/hosts/iron/services/jellyfin/default.nix @@ -3,6 +3,10 @@ let inherit (config.networking) ports; in { + imports = [ + ./rar2fs.nix + ]; + services.jellyfin = { enable = true; }; diff --git a/hosts/iron/services/jellyfin/rar2fs.nix b/hosts/iron/services/jellyfin/rar2fs.nix new file mode 100644 index 0000000..dc634cf --- /dev/null +++ b/hosts/iron/services/jellyfin/rar2fs.nix @@ -0,0 +1,62 @@ +{ lib, pkgs, ... }: + +let + rar2fs = pkgs.rar2fs.override { unrar = pkgs.unrar_6; }; + rar2fs_mounts = pkgs.writeScriptBin "rar2fs_mounts" (lib.strings.concatLines [ + "#!${pkgs.python3}/bin/python" + (builtins.readFile ./rar2fs_mounts.py) + ]); + rar_path = "/var/lib/qBittorrent/downloads"; + mount_path = "/run/jellyfin/rar2fs"; +in +{ + programs.fuse = { + userAllowOther = true; + mountMax = 1000; + }; + + environment.systemPackages = [ + rar2fs + ]; + + systemd.services.jellyfin-rar2fs = { + after = [ "jellyfin.service" ]; + wantedBy = [ "multi-user.target" ]; + path = [ rar2fs "/run/wrappers/bin" ]; + environment.USER = "jellyfin"; + serviceConfig = { + AmbientCapabilities = "CAP_SYS_ADMIN CAP_SETUID CAP_SETGID"; + CapabilityBoundingSet = "CAP_SYS_ADMIN CAP_SETUID CAP_SETGID"; + DeviceAllow = "/dev/fuse rw"; + ExecStart = "${rar2fs_mounts}/bin/rar2fs_mounts ${rar_path} ${mount_path}"; + Group = "jellyfin"; + IPAddressDeny = "any"; + LockPersonality = true; + NoNewPrivileges = "no"; + PrivateDevices = false; + PrivateMounts = false; + PrivateTmp = false; + PrivateUsers = false; + ProtectClock = true; + ProtectControlGroups = false; # implies MountAPIVFS + ProtectHome = false; + ProtectHostname = true; + ProtectKernelLogs = false; + ProtectKernelModules = false; + ProtectKernelTunables = false; # implies MountAPIVFS + #ProtectProc = "noaccess"; # implies MountAPIVFS + ProtectSystem = false; + RestrictAddressFamilies = "none"; + RestrictNamespaces = true; + RestrictRealtime = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ + "@system-service" + "@mount" + "@setuid" + "umount2" + ]; + User = "jellyfin"; + }; + }; +} diff --git a/hosts/iron/services/jellyfin/rar2fs_mounts.py b/hosts/iron/services/jellyfin/rar2fs_mounts.py new file mode 100644 index 0000000..aacbf38 --- /dev/null +++ b/hosts/iron/services/jellyfin/rar2fs_mounts.py @@ -0,0 +1,112 @@ +from pathlib import Path +import argparse +import errno +import os +import signal +import subprocess +import sys +import time + + +mounts = {} + + +class RarMount: + process = None + + @property + def mountpoint(self): + result = self.mount_root / self.rar_file.relative_to(self.rar_root).parent + return result + + def __init__(self, mount_root: str, rar_file: Path, rar_root: Path): + self.mount_root = mount_root + self.rar_file = rar_file + self.rar_root = rar_root + + os.makedirs(self.mountpoint, exist_ok=True) + + print(f"Mounting '{self.rar_file}' at '{self.mountpoint}'") + self.process = subprocess.Popen( + [ + "rar2fs", + "-f", + "-o", + "auto_unmount", + "-o", + "allow_other", + "--no-inherit-perm", + self.rar_file, + self.mountpoint, + ] + ) + + def __del__(self): + if self.process: + self.process.terminate() + self.process.communicate() + + for i in range(10): + try: + os.rmdir(self.mountpoint) + except FileNotFoundError: + pass + except OSError as ex: + # if ex.errno == errno.ENOEMPTY: + # break + if ex.errno == errno.EBUSY: + time.sleep(1) + raise + else: + break + + for dir in self.mountpoint.relative_to(self.mount_root).parents: + try: + os.rmdir(self.mount_root.joinpath(dir)) + except OSError as ex: + pass + + +def signal_handler(sig, frame): + for rar_file, mount in mounts.items(): + del mount + + sys.exit(0) + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Recursively globs a path containing rar files and mounts them under a given mount path." + ) + + parser.add_argument("rar_path", type=Path, help="Path to the RAR directory") + + parser.add_argument("mount_path", type=Path, help="Path to the mount directory") + + return parser.parse_args() + + +def main(): + args = parse_args() + + if not args.rar_path.is_dir(): + parser.error(f"RAR path '{args.rar_path}' is not a valid directory.") + + signal.signal(signal.SIGINT, signal_handler) + + for rar_file in args.rar_path.rglob("*.rar"): + if rar_file in mounts: + continue + if len(rar_file.parts) >= 2 and rar_file.parts[-2].lower() in ["subs", "proof"]: + continue + + mounts[rar_file] = RarMount(args.mount_path, rar_file, args.rar_path) + + while True: + time.sleep(600) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/modules/unfree.nix b/modules/unfree.nix index 77e1c81..a24df1c 100644 --- a/modules/unfree.nix +++ b/modules/unfree.nix @@ -3,7 +3,9 @@ { nixpkgs.config.allowUnfreePredicate = pkg: lib.elem (lib.getName pkg) [ "mongodb" + "rar2fs" "roomeqwizard" "unifi-controller" + "unrar" ]; }