{ config, pkgs, lib, ... }: let inherit (lib) getExe literalExpression mkEnableOption mkIf mkOption mkPackageOption optionalString toUpper types ; cfg = config.services.stash; stashType = types.submodule { options = { path = mkOption { type = types.path; description = "location of your media files"; }; excludevideo = mkOption { type = types.bool; default = false; description = "Whether to exclude video files from being scanned into Stash"; }; excludeimage = mkOption { type = types.bool; default = false; description = "Whether to exclude image files from being scanned into Stash"; }; }; }; stashBoxType = types.submodule { options = { name = mkOption { type = types.str; description = "The name of the Stash Box"; }; endpoint = mkOption { type = types.str; description = "URL to the Stash Box graphql api"; }; apikey = mkOption { type = types.str; description = "Stash Box API key"; }; }; }; recentlyReleased = mode: { __typename = "CustomFilter"; message = { id = "recently_released_objects"; values.objects = mode; }; mode = toUpper mode; sortBy = "date"; direction = "DESC"; }; recentlyAdded = mode: { __typename = "CustomFilter"; message = { id = "recently_added_objects"; values.objects = mode; }; mode = toUpper mode; sortBy = "created_at"; direction = "DESC"; }; uiPresets = { recentlyReleasedScenes = recentlyReleased "Scenes"; recentlyAddedScenes = recentlyAdded "Scenes"; recentlyReleasedGalleries = recentlyReleased "Galleries"; recentlyAddedGalleries = recentlyAdded "Galleries"; recentlyAddedImages = recentlyAdded "Images"; recentlyReleasedMovies = recentlyReleased "Movies"; recentlyAddedMovies = recentlyAdded "Movies"; recentlyAddedStudios = recentlyAdded "Studios"; recentlyAddedPerformers = recentlyAdded "Performers"; }; settingsFormat = pkgs.formats.yaml { }; settingsFile = settingsFormat.generate "config.yml" cfg.settings; settingsType = types.submodule { freeformType = settingsFormat.type; options = { host = mkOption { type = types.str; default = "localhost"; example = "::1"; description = "The ip address that Stash should bind to."; }; port = mkOption { type = types.port; default = 9999; example = 1234; description = "The port that Stash should listen on."; }; stash = mkOption { type = types.listOf stashType; description = '' Add directories containing your adult videos and images. Stash will use these directories to find videos and/or images during scanning. ''; example = literalExpression '' { stash = [ { Path = "/media/drive/videos"; ExcludeImage = true; } ]; } ''; }; stash_boxes = mkOption { type = types.listOf stashBoxType; default = [ ]; description = ''Stash-box facilitates automated tagging of scenes and performers based on fingerprints and filenames''; example = literalExpression '' { stash_boxes = [ { name = "StashDB"; endpoint = "https://stashdb.org/graphql"; apikey = "aaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbb.cccccccccccccc"; } ]; } ''; }; ui.frontPageContent = mkOption { description = "Search filters to display on the front page."; type = types.either (types.listOf types.attrs) (types.functionTo (types.listOf types.attrs)); default = presets: [ presets.recentlyReleasedScenes presets.recentlyAddedStudios presets.recentlyReleasedMovies presets.recentlyAddedPerformers presets.recentlyReleasedGalleries ]; example = literalExpression '' presets: [ # To get the savedFilterId, you can query `{ findSavedFilters(mode: ) { id name } }` on localhost:9999/graphql { __typename = "SavedFilter"; savedFilterId = 1; } # basic custom filter { __typename = "CustomFilter"; title = "Random Scenes"; mode = "SCENES"; sortBy = "random"; direction = "DESC"; } presets.recentlyAddedImages ] ''; apply = type: if builtins.isFunction type then (type uiPresets) else type; }; blobs_path = mkOption { type = types.path; default = "${cfg.dataDir}/blobs"; description = "Path to blobs"; }; cache = mkOption { type = types.path; default = "${cfg.dataDir}/cache"; description = "Path to cache"; }; database = mkOption { type = types.path; default = "${cfg.dataDir}/go.sqlite"; description = "Path to the SQLite database"; }; generated = mkOption { type = types.path; default = "${cfg.dataDir}/generated"; description = "Path to generated files"; }; plugins_path = mkOption { type = types.path; default = "${cfg.dataDir}/plugins"; description = "Path to scrapers"; }; scrapers_path = mkOption { type = types.path; default = "${cfg.dataDir}/scrapers"; description = "Path to scrapers"; }; blobs_storage = mkOption { type = types.enum [ "FILESYSTEM" "DATABASE" ]; default = "FILESYSTEM"; description = "Where to store blobs"; }; calculate_md5 = mkOption { type = types.bool; default = false; description = "Whether to calculate MD5 checksums for scene video files"; }; create_image_clip_from_videos = mkOption { type = types.bool; default = false; description = "Create Image Clips from Video extensions when Videos are disabled in Library"; }; dangerous_allow_public_without_auth = mkOption { type = types.bool; default = false; description = "Learn more at https://docs.stashapp.cc/networking/authentication-required-when-accessing-stash-from-the-internet/"; }; gallery_cover_regex = mkOption { type = types.str; default = "(poster|cover|folder|board)\.[^\.]+$"; description = "Regex used to identify images as gallery covers"; }; no_proxy = mkOption { type = types.str; default = "localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12"; description = "A list of domains for which the proxy must not be used"; }; nobrowser = mkOption { type = types.bool; default = true; description = "If we should not auto-open a browser window on startup"; }; notifications_enabled = mkOption { type = types.bool; default = true; description = "If we should send notifications to the desktop"; }; parallel_tasks = mkOption { type = types.int; default = 1; description = "Number of parallel tasks to start during scan/generate"; }; preview_audio = mkOption { type = types.bool; default = true; description = "Include audio stream in previews"; }; preview_exclude_end = mkOption { type = types.int; default = 0; description = "Duration of start of video to exclude when generating previews"; }; preview_exclude_start = mkOption { type = types.int; default = 0; description = "Duration of end of video to exclude when generating previews"; }; preview_segment_duration = mkOption { type = types.float; default = 0.75; description = "Preview segment duration, in seconds"; }; preview_segments = mkOption { type = types.int; default = 12; description = "Number of segments in a preview file"; }; security_tripwire_accessed_from_public_internet = mkOption { type = types.nullOr types.str; default = ""; description = "Learn more at https://docs.stashapp.cc/networking/authentication-required-when-accessing-stash-from-the-internet/"; }; sequential_scanning = mkOption { type = types.bool; default = false; description = "Modifies behaviour of the scanning functionality to generate support files (previews/sprites/phash) at the same time as fingerprinting/screenshotting"; }; show_one_time_moved_notification = mkOption { type = types.bool; default = true; description = "Whether a small notification to inform the user that Stash will no longer show a terminal window, and instead will be available in the tray"; }; sound_on_preview = mkOption { type = types.bool; default = false; description = "Enable sound on mouseover previews"; }; theme_color = mkOption { type = types.str; default = "#202b33"; description = "Sets the `theme-color` property in the UI"; }; video_file_naming_algorithm = mkOption { type = types.enum [ "OSHASH" "MD5" ]; default = "OSHASH"; description = "Hash algorithm to use for generated file naming"; }; write_image_thumbnails = mkOption { type = types.bool; default = true; description = "Write image thumbnails to disk when generating on the fly"; }; }; }; pluginType = kind: mkOption { type = types.listOf types.package; default = [ ]; description = '' The ${kind} Stash should be started with. ''; apply = srcs: optionalString (srcs != [ ]) ( pkgs.runCommand "stash-${kind}" { inherit srcs; nativeBuildInputs = [ pkgs.yq-go ]; preferLocalBuild = true; } '' find $srcs -mindepth 1 -name '*.yml' | while read plugin_file; do grep -q "^#pkgignore" "$plugin_file" && continue plugin_dir=$(dirname $plugin_file) out_path=$out/$(basename $plugin_dir) mkdir -p $out_path ls $plugin_dir | xargs -I{} ln -sf "$plugin_dir/{}" $out_path env \ plugin_id=$(basename $plugin_file .yml) \ plugin_name="$(yq '.name' $plugin_file)" \ plugin_description="$(yq '.description' $plugin_file)" \ plugin_version="$(yq '.version' $plugin_file)" \ plugin_files="$(find -L $out_path -mindepth 1 -type f -printf "%P\n")" \ yq -n ' .id = strenv(plugin_id) | .name = strenv(plugin_name) | ( strenv(plugin_description) as $desc | with(select($desc == "null"); .metadata = {}) | with(select($desc != "null"); .metadata.description = $desc) ) | ( strenv(plugin_version) as $ver | with(select($ver == "null"); .version = "Unknown") | with(select($ver != "null"); .version = $ver) ) | .date = (now | format_datetime("2006-01-02 15:04:05")) | .files = (strenv(plugin_files) | split("\n")) ' > $out_path/manifest done '' ); }; in { meta = { buildDocsInSandbox = false; maintainers = with lib.maintainers; [ DrakeTDL ]; }; options = { services.stash = { enable = mkEnableOption "stash"; package = mkPackageOption pkgs "stash" { }; user = mkOption { type = types.str; default = "stash"; description = "User under which Stash runs."; }; group = mkOption { type = types.str; default = "stash"; description = "Group under which Stash runs."; }; dataDir = mkOption { type = types.path; default = "/var/lib/stash"; description = "The directory where Stash stores its files."; }; openFirewall = mkOption { type = types.bool; default = false; description = "Open ports in the firewall for the Stash web interface."; }; username = mkOption { type = types.nullOr types.nonEmptyStr; default = null; example = "admin"; description = '' Username for login. ::: {.warning} This option takes precedence over {option}`services.stash.settings.username` :: ''; }; passwordFile = mkOption { type = types.nullOr types.path; default = null; example = "/path/to/password/file"; description = '' Path to file containing password for login. ::: {.warning} This option takes precedence over {option}`services.stash.settings.password` :: ''; }; jwtSecretKeyFile = mkOption { type = types.path; description = "Path to file containing a secret used to sign JWT tokens."; }; sessionStoreKeyFile = mkOption { type = types.path; description = "Path to file containing a secret for session store."; }; mutableSettings = mkOption { description = '' Whether the Stash config.yml is writeable by Stash. If `false`, Any config changes done from within Stash UI will be temporary and reset to those defined in {option}`services.stash.settings` upon `Stash.service` restart. If `true`, the {option}`services.stash.settings` will only be used to initialize the Stash configuration if it does not exist, and are subsequently ignored. ''; type = types.bool; default = true; }; mutablePlugins = mkEnableOption "Whether plugins/themes can be installed, updated, uninstalled manually."; mutableScrapers = mkEnableOption "Whether scrapers can be installed, updated, uninstalled manually."; plugins = pluginType "plugins"; scrapers = pluginType "scrapers"; settings = mkOption { type = settingsType; description = "Stash configuration"; }; }; }; config = mkIf cfg.enable { assertions = [ { assertion = !lib.xor (cfg.username != null || cfg.settings.username or null != null) ( cfg.passwordFile != null || cfg.settings.password or null != null ); message = "You must set either both username and password, or neither."; } ]; services.stash.settings = { username = mkIf (cfg.username != null) cfg.username; plugins_path = mkIf (!cfg.mutablePlugins) cfg.plugins; scrapers_path = mkIf (!cfg.mutableScrapers) cfg.scrapers; }; networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.settings.port ]; users.users.${cfg.user} = { inherit (cfg) group; isSystemUser = true; home = cfg.dataDir; }; users.groups.${cfg.group} = { }; systemd = { tmpfiles.settings."10-stash-datadir".${cfg.dataDir}."d" = { inherit (cfg) user group; mode = "0755"; }; services.stash = { wantedBy = [ "multi-user.target" ]; after = [ "network.target" ]; path = with pkgs; [ ffmpeg-full python3 ruby ]; environment.STASH_CONFIG_FILE = "${cfg.dataDir}/config.yml"; serviceConfig = { DynamicUser = false; User = cfg.user; Group = cfg.group; Restart = "on-failure"; WorkingDirectory = cfg.dataDir; StateDirectory = mkIf (cfg.dataDir == "/var/lib/stash") (baseNameOf cfg.dataDir); ExecStartPre = pkgs.writers.writeBash "stash-setup.bash" ( '' install -d ${cfg.settings.generated} if [[ ! -z "${toString cfg.mutableSettings}" || ! -f ${cfg.dataDir}/config.yml ]]; then env \ password=$(< ${cfg.passwordFile}) \ jwtSecretKeyFile=$(< ${cfg.jwtSecretKeyFile}) \ sessionStoreKeyFile=$(< ${cfg.sessionStoreKeyFile}) \ ${lib.getExe pkgs.yq-go} ' .jwt_secret_key = strenv(jwtSecretKeyFile) | .session_store_key = strenv(sessionStoreKeyFile) | ( strenv(password) as $password | with(select($password != ""); .password = $password) ) ' ${settingsFile} > ${cfg.dataDir}/config.yml fi '' + optionalString cfg.mutablePlugins '' install -d ${cfg.settings.plugins_path} ls ${cfg.plugins} | xargs -I{} ln -sf '${cfg.plugins}/{}' ${cfg.settings.plugins_path} '' + optionalString cfg.mutableScrapers '' install -d ${cfg.settings.scrapers_path} ls ${cfg.scrapers} | xargs -I{} ln -sf '${cfg.scrapers}/{}' ${cfg.settings.scrapers_path} '' ); ExecStart = getExe cfg.package; ProtectHome = "tmpfs"; BindReadOnlyPaths = mkIf (cfg.settings != { }) (map (stash: "${stash.path}") cfg.settings.stash); # hardening DevicePolicy = "auto"; # needed for hardware acceleration PrivateDevices = false; # needed for hardware acceleration AmbientCapabilities = [ "" ]; CapabilityBoundingSet = [ "" ]; ProtectSystem = "full"; LockPersonality = true; NoNewPrivileges = true; PrivateTmp = true; PrivateUsers = true; ProtectClock = true; ProtectControlGroups = true; ProtectHostname = true; ProtectKernelLogs = true; ProtectKernelModules = true; ProtectKernelTunables = true; ProcSubset = "pid"; ProtectProc = "invisible"; RemoveIPC = true; RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ]; RestrictNamespaces = true; RestrictRealtime = true; RestrictSUIDSGID = true; MemoryDenyWriteExecute = true; SystemCallArchitectures = "native"; SystemCallFilter = [ "~@cpu-emulation" "~@debug" "~@mount" "~@obsolete" "~@privileged" ]; }; }; }; }; }